<?php
/**
 * Product CSV import use case.
 *
 * @package Affilun\Application\Product\Csv
 */

declare(strict_types=1);

namespace Affilun\Application\Product\Csv;

use Affilun\Application\Product\ProductManualTaxonomy;
use Affilun\Infrastructure\Persistence\ProductListQuery;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Validates, previews, and applies saved-product CSV imports.
 */
final class ProductCsvImportUseCase {

	/**
	 * Default provider key allow-list.
	 *
	 * @var list<string>
	 */
	private const DEFAULT_ALLOWED_PROVIDERS = array(
		'amazon',
		'rakuten',
		'yahoo_shopping',
		'mercari',
	);

	/**
	 * Required CSV headers.
	 *
	 * @var list<string>
	 */
	private const REQUIRED_HEADERS = array(
		'provider',
		'provider_item_id',
		'title',
		'detail_url',
		'lifecycle_state',
	);

	/**
	 * Product repository.
	 */
	private ProductRepository $products;

	/**
	 * Manual category/tag helper.
	 */
	private ProductManualTaxonomy $taxonomy;

	/**
	 * Allowed provider keys.
	 *
	 * @var list<string>
	 */
	private array $allowedProviders;

	/**
	 * @param ProductRepository     $products         Product repository.
	 * @param ProductManualTaxonomy $taxonomy         Manual taxonomy helper.
	 * @param list<string>          $allowedProviders Allowed provider keys.
	 */
	public function __construct(
		ProductRepository $products,
		ProductManualTaxonomy $taxonomy,
		array $allowedProviders = self::DEFAULT_ALLOWED_PROVIDERS
	) {
		$this->products         = $products;
		$this->taxonomy         = $taxonomy;
		$this->allowedProviders = self::normalizeAllowedProviders( $allowedProviders );
	}

	/**
	 * Preview an import without mutating saved products.
	 *
	 * @param string $csv CSV payload.
	 */
	public function preview( string $csv ): ProductCsvImportResult {
		$analysis = $this->analyze( $csv );
		return $analysis['result'];
	}

	/**
	 * Apply an import after the caller has shown a preview.
	 *
	 * @param string $csv CSV payload.
	 *
	 * @throws InvalidArgumentException When the CSV is invalid.
	 */
	public function apply( string $csv ): ProductCsvImportResult {
		$analysis = $this->analyze( $csv );
		$result   = $analysis['result'];
		if ( $result->hasErrors() ) {
			throw new InvalidArgumentException( 'CSV import contains validation errors.' );
		}

		$created = 0;
		$updated = 0;
		foreach ( $analysis['rows'] as $row ) {
			$existing = $this->products->findByProviderItem( $row['provider'], $row['provider_item_id'] );
			$saved    = $this->saveRow( $row, $existing );
			$id       = $saved->id();
			if ( $id !== null ) {
				$this->taxonomy->saveCategoriesForProduct( $id, $row['categories'] );
				$this->taxonomy->saveTagsForProduct( $id, $row['tags'] );
			}

			if ( $existing === null ) {
				++$created;
			} else {
				++$updated;
			}
		}

		return new ProductCsvImportResult( $result->rows(), array(), $created, $updated );
	}

	/**
	 * Parse and validate CSV.
	 *
	 * @param string $csv CSV payload.
	 *
	 * @return array{result:ProductCsvImportResult,rows:list<array<string,mixed>>}
	 */
	private function analyze( string $csv ): array {
		$parsed = self::parseCsv( $csv );
		if ( $parsed['errors'] !== array() ) {
			return array(
				'result' => new ProductCsvImportResult( array(), $parsed['errors'] ),
				'rows'   => array(),
			);
		}

		$headerErrors = self::validateHeaders( $parsed['headers'] );
		if ( $headerErrors !== array() ) {
			return array(
				'result' => new ProductCsvImportResult( array(), $headerErrors ),
				'rows'   => array(),
			);
		}

		$previewRows = array();
		$validRows   = array();
		$seenPairs   = array();
		$rowNumber   = 1;
		foreach ( $parsed['rows'] as $csvRow ) {
			++$rowNumber;
			$validated     = $this->validateRow( $csvRow, $parsed['headers'], $rowNumber, $seenPairs );
			$previewRows[] = $validated['preview'];
			if ( $validated['preview']->isValid() ) {
				$validRows[] = $validated['data'];
			}
		}

		if ( $previewRows === array() ) {
			return array(
				'result' => new ProductCsvImportResult( array(), array( 'CSV contains no product rows.' ) ),
				'rows'   => array(),
			);
		}

		return array(
			'result' => new ProductCsvImportResult( $previewRows ),
			'rows'   => $validRows,
		);
	}

	/**
	 * Validate one row.
	 *
	 * @param array<int,string>  $csvRow    CSV row cells.
	 * @param array<string,int>  $headers   Header map.
	 * @param int                $rowNumber Physical row number.
	 * @param array<string,bool> $seenPairs Provider/item pairs already seen.
	 *
	 * @return array{preview:ProductCsvImportRow,data:array<string,mixed>}
	 */
	private function validateRow(
		array $csvRow,
		array $headers,
		int $rowNumber,
		array &$seenPairs
	): array {
		$errors         = array();
		$provider       = self::cell( $csvRow, $headers, 'provider' );
		$providerItemId = self::cell( $csvRow, $headers, 'provider_item_id' );
		$title          = self::cell( $csvRow, $headers, 'title' );
		$lifecycleState = self::cell( $csvRow, $headers, 'lifecycle_state' );
		$syncState      = self::cell( $csvRow, $headers, 'sync_state' );
		$detailUrl      = self::cell( $csvRow, $headers, 'detail_url' );
		$imageUrl       = self::cell( $csvRow, $headers, 'image_primary_url' );
		$affiliateUrl   = self::cell( $csvRow, $headers, 'affiliate_url' );
		$categories     = ProductManualTaxonomy::termsFromInput( self::cell( $csvRow, $headers, 'categories' ) );
		$tags           = ProductManualTaxonomy::termsFromInput( self::cell( $csvRow, $headers, 'tags' ) );

		if ( $provider === '' ) {
			$errors[] = 'Provider is required.';
		} elseif ( ! in_array( $provider, $this->allowedProviders, true ) ) {
			$errors[] = sprintf( 'Provider "%s" is not supported.', $provider );
		}

		if ( $providerItemId === '' ) {
			$errors[] = 'Provider item id is required.';
		}

		if ( $title === '' ) {
			$errors[] = 'Title is required.';
		}

		if ( ! in_array( $lifecycleState, ProductListQuery::validLifecycleStates(), true ) ) {
			$errors[] = 'Lifecycle state is invalid.';
		}

		if ( $syncState === '' ) {
			$syncState = ProductRecord::DEFAULT_SYNC_STATE;
		} elseif ( ! in_array( $syncState, ProductListQuery::validSyncStates(), true ) ) {
			$errors[] = 'Sync state is invalid.';
		}

		if ( ! self::isHttpUrl( $detailUrl ) ) {
			$errors[] = 'Detail URL must be an HTTP(S) URL.';
		}

		if ( $imageUrl !== '' && ! self::isHttpUrl( $imageUrl ) ) {
			$errors[] = 'Image URL must be an HTTP(S) URL.';
		}

		if ( $affiliateUrl !== '' && ! self::isHttpUrl( $affiliateUrl ) ) {
			$errors[] = 'Affiliate URL must be an HTTP(S) URL.';
		}

		$pairKey = $provider . "\0" . $providerItemId;
		if ( $provider !== '' && $providerItemId !== '' ) {
			if ( array_key_exists( $pairKey, $seenPairs ) ) {
				$errors[] = 'Duplicate provider/item pair in CSV.';
			}
			$seenPairs[ $pairKey ] = true;
		}

		$existing = null;
		if ( $provider !== '' && $providerItemId !== '' ) {
			$existing = $this->products->findByProviderItem( $provider, $providerItemId );
		}
		$action = $existing === null ? ProductCsvImportRow::ACTION_CREATE : ProductCsvImportRow::ACTION_UPDATE;

		$data = array(
			'provider'               => $provider,
			'provider_item_id'       => $providerItemId,
			'canonical_hash'         => self::optionalCell( $csvRow, $headers, 'canonical_hash' ),
			'title'                  => $title,
			'brand'                  => self::optionalCell( $csvRow, $headers, 'brand' ),
			'description'            => self::optionalCell( $csvRow, $headers, 'description' ),
			'image_primary_url'      => $imageUrl === '' ? null : $imageUrl,
			'detail_url'             => $detailUrl,
			'affiliate_url'          => $affiliateUrl === '' ? null : $affiliateUrl,
			'price_amount'           => self::optionalCell( $csvRow, $headers, 'price_amount' ),
			'price_currency'         => self::optionalCell( $csvRow, $headers, 'price_currency' ),
			'price_label'            => self::optionalCell( $csvRow, $headers, 'price_label' ),
			'availability_status'    => self::optionalCell( $csvRow, $headers, 'availability_status' ),
			'search_keywords'        => self::optionalCell( $csvRow, $headers, 'search_keywords' ),
			'search_keywords_source' => self::optionalCell( $csvRow, $headers, 'search_keywords_source' ),
			'sync_state'             => $syncState,
			'lifecycle_state'        => $lifecycleState,
			'categories'             => $categories,
			'tags'                   => $tags,
		);

		return array(
			'preview' => new ProductCsvImportRow(
				$rowNumber,
				$action,
				$provider,
				$providerItemId,
				$title,
				$lifecycleState,
				$categories,
				$tags,
				$errors
			),
			'data'    => $data,
		);
	}

	/**
	 * Save one normalized import row.
	 *
	 * @param array<string,mixed> $row      Row data.
	 * @param ProductRecord|null  $existing Existing record.
	 */
	private function saveRow( array $row, ?ProductRecord $existing ): ProductRecord {
		$now        = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
		$archivedAt = $row['lifecycle_state'] === ProductListQuery::LIFECYCLE_STATE_ACTIVE ? null : $now;
		$hash       = is_string( $row['canonical_hash'] ) && $row['canonical_hash'] !== ''
			? $row['canonical_hash']
			: self::fallbackCanonicalHash( $row );

		if ( $existing === null ) {
			return $this->products->save(
				ProductRecord::createNew(
					$row['provider'],
					$row['provider_item_id'],
					$hash,
					$row['title'],
					$row['detail_url'],
					$now,
					array(
						'brand'                => $row['brand'],
						'description'          => $row['description'],
						'imagePrimaryUrl'      => $row['image_primary_url'],
						'priceAmount'          => $row['price_amount'],
						'priceCurrency'        => $row['price_currency'],
						'priceLabel'           => $row['price_label'],
						'availabilityStatus'   => $row['availability_status'],
						'affiliateUrl'         => $row['affiliate_url'],
						'syncState'            => $row['sync_state'],
						'lifecycleState'       => $row['lifecycle_state'],
						'archivedAt'           => $archivedAt,
						'searchKeywords'       => $row['search_keywords'],
						'searchKeywordsSource' => $row['search_keywords_source'],
					)
				)
			);
		}

		$recordRow                           = $existing->toArray();
		$recordRow['canonical_hash']         = $hash;
		$recordRow['title']                  = $row['title'];
		$recordRow['brand']                  = $row['brand'];
		$recordRow['description']            = $row['description'];
		$recordRow['image_primary_url']      = $row['image_primary_url'];
		$recordRow['detail_url']             = $row['detail_url'];
		$recordRow['affiliate_url']          = $row['affiliate_url'];
		$recordRow['price_amount']           = $row['price_amount'];
		$recordRow['price_currency']         = $row['price_currency'];
		$recordRow['price_label']            = $row['price_label'];
		$recordRow['availability_status']    = $row['availability_status'];
		$recordRow['search_keywords']        = $row['search_keywords'];
		$recordRow['search_keywords_source'] = $row['search_keywords_source'];
		$recordRow['sync_state']             = $row['sync_state'];
		$recordRow['lifecycle_state']        = $row['lifecycle_state'];
		$recordRow['archived_at']            = $archivedAt;
		$recordRow['updated_at']             = $now;

		return $this->products->save( ProductRecord::fromArray( $recordRow ) );
	}

	/**
	 * Parse CSV into a header map and data rows.
	 *
	 * @param string $csv CSV payload.
	 *
	 * @return array{headers:array<string,int>,rows:list<array<int,string>>,errors:list<string>}
	 */
	private static function parseCsv( string $csv ): array {
		$csv = trim( str_replace( "\0", '', $csv ) );
		if ( $csv === '' ) {
			return array(
				'headers' => array(),
				'rows'    => array(),
				'errors'  => array( 'CSV content is empty.' ),
			);
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Uses in-memory php://temp for pure CSV parsing; no filesystem path is touched.
		$handle = fopen( 'php://temp', 'r+' );
		if ( $handle === false ) {
			return array(
				'headers' => array(),
				'rows'    => array(),
				'errors'  => array( 'Unable to open CSV parser.' ),
			);
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writes caller-provided CSV to in-memory php://temp for fgetcsv parsing.
		fwrite( $handle, $csv );
		rewind( $handle );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgetcsv -- Parses in-memory CSV content.
		$headerRow = fgetcsv( $handle, 0, ',', '"', '' );
		if ( ! is_array( $headerRow ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes in-memory php://temp parser handle.
			fclose( $handle );
			return array(
				'headers' => array(),
				'rows'    => array(),
				'errors'  => array( 'CSV header row is missing.' ),
			);
		}

		$headers = array();
		foreach ( $headerRow as $index => $header ) {
			$key = self::normalizeHeader( is_scalar( $header ) ? (string) $header : '' );
			if ( $key !== '' && ! array_key_exists( $key, $headers ) ) {
				$headers[ $key ] = (int) $index;
			}
		}

		$rows = array();
		while ( true ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgetcsv -- Parses in-memory CSV content.
			$row = fgetcsv( $handle, 0, ',', '"', '' );
			if ( $row === false ) {
				break;
			}
			if ( ! is_array( $row ) || self::isEmptyCsvRow( $row ) ) {
				continue;
			}
			$rows[] = array_map(
				static fn ( $value ): string => is_scalar( $value ) ? (string) $value : '',
				$row
			);
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes in-memory php://temp parser handle.
		fclose( $handle );

		return array(
			'headers' => $headers,
			'rows'    => $rows,
			'errors'  => array(),
		);
	}

	/**
	 * Validate required headers.
	 *
	 * @param array<string,int> $headers Header map.
	 *
	 * @return list<string>
	 */
	private static function validateHeaders( array $headers ): array {
		$errors = array();
		foreach ( self::REQUIRED_HEADERS as $header ) {
			if ( ! array_key_exists( $header, $headers ) ) {
				$errors[] = sprintf( 'Required CSV header "%s" is missing.', $header );
			}
		}

		return $errors;
	}

	/**
	 * Read one normalized cell.
	 *
	 * @param array<int,string> $row     CSV row.
	 * @param array<string,int> $headers Header map.
	 * @param string            $key     Header key.
	 */
	private static function cell( array $row, array $headers, string $key ): string {
		if ( ! array_key_exists( $key, $headers ) ) {
			return '';
		}

		$index = $headers[ $key ];
		$value = $row[ $index ] ?? '';
		$value = trim( str_replace( "\0", '', $value ) );
		$value = preg_replace( '/^\xEF\xBB\xBF/u', '', $value );
		$value = is_string( $value ) ? $value : '';

		return self::unescapeFormulaCell( $value );
	}

	/**
	 * Read one optional normalized cell.
	 *
	 * @param array<int,string> $row     CSV row.
	 * @param array<string,int> $headers Header map.
	 * @param string            $key     Header key.
	 */
	private static function optionalCell( array $row, array $headers, string $key ): ?string {
		$value = self::cell( $row, $headers, $key );
		return $value === '' ? null : $value;
	}

	/**
	 * Normalize a header key.
	 *
	 * @param string $header Raw header.
	 */
	private static function normalizeHeader( string $header ): string {
		$header = preg_replace( '/^\xEF\xBB\xBF/u', '', $header );
		$header = is_string( $header ) ? $header : '';
		return strtolower( trim( $header ) );
	}

	/**
	 * Whether a parsed row has no meaningful cells.
	 *
	 * @param array<int,mixed> $row Row.
	 */
	private static function isEmptyCsvRow( array $row ): bool {
		foreach ( $row as $cell ) {
			if ( is_scalar( $cell ) && trim( (string) $cell ) !== '' ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Reverse the exporter's spreadsheet formula prefix.
	 *
	 * @param string $value Cell value.
	 */
	private static function unescapeFormulaCell( string $value ): string {
		if ( strlen( $value ) >= 2 && $value[0] === "'" && self::isFormulaTrigger( $value[1] ) ) {
			return substr( $value, 1 );
		}

		return $value;
	}

	/**
	 * Whether a value starts with a spreadsheet formula trigger.
	 *
	 * @param string $char First character.
	 */
	private static function isFormulaTrigger( string $char ): bool {
		return in_array( $char, array( '=', '+', '-', '@', "\t", "\r", "\n" ), true );
	}

	/**
	 * Validate HTTP(S) URL.
	 *
	 * @param string $value URL.
	 */
	private static function isHttpUrl( string $value ): bool {
		if ( $value === '' ) {
			return false;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Pure application validation also runs outside WordPress.
		$parts = parse_url( $value );
		if ( ! is_array( $parts ) ) {
			return false;
		}

		$scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) );
		$host   = (string) ( $parts['host'] ?? '' );
		return in_array( $scheme, array( 'http', 'https' ), true ) && $host !== '';
	}

	/**
	 * Deterministic fallback canonical hash for imported rows.
	 *
	 * @param array<string,mixed> $row Import row.
	 */
	private static function fallbackCanonicalHash( array $row ): string {
		$payload = strtolower(
			(string) $row['provider'] . "\0"
			. (string) $row['provider_item_id'] . "\0"
			. (string) $row['title'] . "\0"
			. (string) $row['detail_url']
		);

		return hash( 'sha256', $payload );
	}

	/**
	 * Normalize allowed providers.
	 *
	 * @param list<string> $providers Providers.
	 *
	 * @return list<string>
	 */
	private static function normalizeAllowedProviders( array $providers ): array {
		$normalized = array();
		foreach ( $providers as $provider ) {
			$provider = trim( $provider );
			if ( $provider !== '' && ! in_array( $provider, $normalized, true ) ) {
				$normalized[] = $provider;
			}
		}

		return $normalized === array() ? self::DEFAULT_ALLOWED_PROVIDERS : $normalized;
	}
}
