<?php
/**
 * 精算明細マネージャ（窓口返金・追加徴収）
 *
 * 会員区分の誤り等で「請求額」と「入金額」に差が生じた場合の、
 * 窓口での返金・追加徴収を明細として記録する。
 *
 * 決済レコード（payments）は「請求額（amount + tax）」と
 * 「入金額（paid_amount）」を保持し、その後の増減はこの明細で積む。
 *
 *   純入金額 = paid_amount − Σ refund + Σ surcharge
 *
 * 明細方式にすることで、複数回の窓口対応・現金と振込の混在・
 * 対応日時と担当者の記録が履歴として残る。
 *
 * @package ConfManager
 * @since   1.1.9
 */

declare(strict_types=1);

namespace ConfManager\Payment;

use ConfManager\Traits\Singleton;
use ConfManager\Security\AuditLog;

defined( 'ABSPATH' ) || exit;

class PaymentAdjustmentManager {

	use Singleton;

	private const TABLE = 'conf_payment_adjustments';

	/** 返金（参加者へ返す） */
	public const TYPE_REFUND = 'refund';

	/** 追加徴収（参加者から受け取る） */
	public const TYPE_SURCHARGE = 'surcharge';

	private const VALID_TYPES = [ self::TYPE_REFUND, self::TYPE_SURCHARGE ];

	/** 窓口現金を含む精算手段 */
	private const VALID_METHODS = [ 'cash', 'bank', 'stripe', 'peatix' ];

	/**
	 * 「生きている明細」の条件（v1.6.5 / DB 3.9.0）
	 *
	 * 🔴 **精算明細を SUM する SQL は、1 つ残らずこの条件を通すこと。**
	 * 1 箇所でも落とすと、そこだけが取り消したはずの返金を数え続け、
	 * 画面の差額と集計・CSV が**何も表示しないまま**食い違う。
	 * 参照している場所（Free は 4 箇所）は静的検査 M-15 が本数ごと見張っている。
	 *
	 * ⚠️ `$wpdb->prepare()` の第 1 引数に `" . self::EFFECTIVE_SQL . "` と
	 * **連結して**渡さないこと。Plugin Check が NotPrepared を **ERROR** で出し、
	 * WP.org 配布が止まる。ローカル変数に受けて `{$effective}` と埋め込む。
	 */
	public const EFFECTIVE_SQL = 'deleted_at IS NULL';

	private function table(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE;
	}

	public static function valid_types(): array {
		return self::VALID_TYPES;
	}

	public static function valid_methods(): array {
		return self::VALID_METHODS;
	}

	/**
	 * 精算明細を登録する
	 *
	 * @param array $data {
	 *     @type int    $payment_id  決済ID（必須）
	 *     @type string $type        refund | surcharge（必須）
	 *     @type int    $amount      税込の実額。正の数（必須）
	 *     @type string $method      cash | bank | stripe | peatix（既定 cash）
	 *     @type string $occurred_at 実施日時（Y-m-d H:i:s / 省略時は現在時刻）
	 *     @type string $reason      理由
	 * }
	 * @return int|\WP_Error 明細ID
	 */
	public function create( array $data ): int|\WP_Error {
		global $wpdb;

		$payment_id = absint( $data['payment_id'] ?? 0 );
		$payment    = PaymentManager::instance()->get( $payment_id );
		if ( null === $payment ) {
			return new \WP_Error( 'not_found', __( 'Payment record not found.', 'conferencemanager' ) );
		}

		$type = (string) ( $data['type'] ?? '' );
		if ( ! in_array( $type, self::VALID_TYPES, true ) ) {
			return new \WP_Error( 'invalid_type', __( 'Invalid settlement type.', 'conferencemanager' ) );
		}

		$amount = (int) ( $data['amount'] ?? 0 );
		if ( $amount <= 0 ) {
			return new \WP_Error( 'invalid_amount', __( 'Settlement amount must be 1 or more.', 'conferencemanager' ) );
		}

		$method = (string) ( $data['method'] ?? 'cash' );
		if ( ! in_array( $method, self::VALID_METHODS, true ) ) {
			return new \WP_Error( 'invalid_method', __( 'Invalid settlement method.', 'conferencemanager' ) );
		}

		// 返金は「これまでに受け取った額」を超えられない（過剰返金の防止）
		if ( self::TYPE_REFUND === $type ) {
			$net = $this->get_net_paid( $payment );
			if ( $amount > $net ) {
				return new \WP_Error(
					'refund_exceeds_paid',
					__( 'Refund amount exceeds the amount received.', 'conferencemanager' )
				);
			}
		}

		$occurred_at = sanitize_text_field( (string) ( $data['occurred_at'] ?? '' ) );
		if ( '' === $occurred_at || false === strtotime( $occurred_at ) ) {
			$occurred_at = current_time( 'mysql', true );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		$result = $wpdb->insert(
			$this->table(),
			[
				'payment_id'      => $payment_id,
				'registration_id' => (int) $payment['registration_id'],
				'type'            => $type,
				'amount'          => $amount,
				'method'          => $method,
				'occurred_at'     => $occurred_at,
				'handled_by'      => get_current_user_id() ?: null,
				'reason'          => sanitize_textarea_field( (string) ( $data['reason'] ?? '' ) ),
			]
		);

		if ( false === $result ) {
			return new \WP_Error( 'db_insert_error', __( 'Failed to save settlement record.', 'conferencemanager' ), $wpdb->last_error );
		}

		$id = (int) $wpdb->insert_id;

		AuditLog::instance()->log( 'create_payment_adjustment', 'payment', $payment_id, [
			'adjustment_id'   => $id,
			'registration_id' => (int) $payment['registration_id'],
			'type'            => $type,
			'amount'          => $amount,
			'method'          => $method,
			'occurred_at'     => $occurred_at,
		] );

		/**
		 * 精算明細の登録後に発火
		 *
		 * @since 1.1.9
		 * @param int   $id         明細ID
		 * @param int   $payment_id 決済ID
		 * @param array $data       登録内容
		 */
		do_action( 'conf_manager_payment_adjusted', $id, $payment_id, $data );

		return $id;
	}

	/**
	 * 決済に紐づく精算明細を取得する（古い順）
	 *
	 * @param int $payment_id 決済ID
	 * @return array
	 */
	public function get_by_payment( int $payment_id ): array {
		global $wpdb;

		// 取り消した明細は履歴からも外す（起きていない取引を帳簿に残さないため）。
		$effective = self::EFFECTIVE_SQL;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$this->table()} WHERE payment_id = %d AND {$effective} ORDER BY occurred_at ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method; effective condition is a class constant.
				$payment_id
			),
			ARRAY_A
		);

		$items = array_map( [ $this, 'format_row' ], $rows ?: [] );

		// 🔴 取り消せるかの判定は delete_blocker() 1 箇所だけを通す。
		// 画面がここを見て操作ボタンを出すので、サーバが断る条件とずれない。
		foreach ( $items as $i => $item ) {
			$blocker                            = $this->delete_blocker( $item );
			$data                               = $blocker ? $blocker->get_error_data() : [];
			$items[ $i ]['can_delete']          = null === $blocker;
			$items[ $i ]['delete_blocked_note'] = null === $blocker ? '' : $blocker->get_error_message();
			// 文言だけでなく機械可読な理由も返す（画面・API・検査が同じコードを見る）。
			// ⚠️ 文言は翻訳で変わるので、判定に使わせない。
			$items[ $i ]['delete_blocked_code'] = is_array( $data ) ? ( $data['reason'] ?? '' ) : '';
		}

		return $items;
	}

	/**
	 * 明細を 1 件取得する（取り消し済みも返す）
	 *
	 * ⚠️ 一覧（{@see self::get_by_payment()}）と違い**取り消し済みも返す**。
	 * 二重取り消しを断るには、取り消された行そのものが読めないといけない。
	 *
	 * @param int $id 明細ID
	 * @return array|null
	 */
	public function get( int $id ): ?array {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$this->table()} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$id
			),
			ARRAY_A
		);

		return $row ? $this->format_row( $row ) : null;
	}

	/**
	 * 取り消せない理由を返す（取り消せるなら null）
	 *
	 * 🔴 **可否の判定はここ 1 箇所に閉じる。** {@see self::delete()} と、
	 * 一覧が返す `can_delete` が同じ関数を通る。画面側に条件を書くと必ずずれ、
	 * 「押せるのに 400 が返る」か「取り消せるのにボタンが出ない」になる。
	 *
	 * @param array $adjustment 明細（{@see self::get()} の戻り値）
	 * @return \WP_Error|null
	 */
	public function delete_blocker( array $adjustment ): ?\WP_Error {
		if ( ! empty( $adjustment['deleted_at'] ) ) {
			return new \WP_Error(
				'delete_not_allowed',
				__( 'This settlement record has already been cancelled.', 'conferencemanager' ),
				[ 'reason' => 'already_deleted' ]
			);
		}

		$payment = PaymentManager::instance()->get( (int) $adjustment['payment_id'] );
		if ( null === $payment ) {
			return new \WP_Error(
				'delete_not_allowed',
				__( 'Payment record not found.', 'conferencemanager' ),
				[ 'reason' => 'payment_missing' ]
			);
		}

		// 🔴 refunded / partial_refund の明細は PaymentManager::refund() が作ったもので、
		// **決済ステータスの遷移・申込のキャンセル**（過去のカード決済なら実返金も）を
		// 伴う。明細だけ消してもそれらは巻き戻らないので、ここでは扱わない。
		if ( 'completed' !== $payment['status'] ) {
			return new \WP_Error(
				'delete_not_allowed',
				__( 'Only settlement records on a paid payment can be cancelled. Refunds that cancelled the registration are not undone here.', 'conferencemanager' ),
				[
					// ⚠️ reason の語は Pro 線と揃えてある（両線の正典・チェックリストが
					//    同じ語で読めるように）。片方だけ変えないこと。
					'reason' => 'payment_not_completed',
					'status' => (string) $payment['status'],
				]
			);
		}

		// 追加徴収を取り消すと純入金額が下がる。既に返金した額を下回るなら認めない。
		if ( self::TYPE_SURCHARGE === $adjustment['type'] ) {
			$net = $this->get_net_paid( $payment );
			if ( $net - (int) $adjustment['amount'] < 0 ) {
				return new \WP_Error(
					'delete_not_allowed',
					__( 'Cancelling this surcharge would make the net amount received negative.', 'conferencemanager' ),
					[ 'reason' => 'net_would_be_negative' ]
				);
			}
		}

		return null;
	}

	/**
	 * 誤って記録した精算明細を取り消す（論理削除）
	 *
	 * 🔴 **これは会計上の「取消取引」ではなく「誤記入の訂正」。**
	 * 実際に返金したものを取りやめたのではなく、製品の欠陥で
	 * **起きてもいない取引が帳簿に載った**場合に使う。打ち消し行を積むと
	 * 存在しなかった取引が 2 行に増えるので、**履歴からも集計からも外す**。
	 *
	 * 行は DB に残るので復元できる。ただし履歴には出なくなるため、
	 * **消す前に全項目を監査ログへ**入れる（追える手がかりはログだけになる）。
	 *
	 * @since 1.6.5
	 * @param int $id 明細ID
	 * @return true|\WP_Error
	 */
	public function delete( int $id ): true|\WP_Error {
		global $wpdb;

		$row = $this->get( $id );
		if ( null === $row ) {
			return new \WP_Error( 'not_found', __( 'Settlement record not found.', 'conferencemanager' ) );
		}

		$blocker = $this->delete_blocker( $row );
		if ( null !== $blocker ) {
			return $blocker;
		}

		$user_id = get_current_user_id() ?: null;

		// ⚠️ 監査ログは**消す前に**。履歴から外れた後は、ここに残した値だけが手がかり。
		AuditLog::instance()->log( 'delete_payment_adjustment', 'payment', (int) $row['payment_id'], [
			'adjustment_id'   => (int) $row['id'],
			'registration_id' => (int) $row['registration_id'],
			'type'            => (string) $row['type'],
			'amount'          => (int) $row['amount'],
			'method'          => (string) $row['method'],
			'occurred_at'     => (string) $row['occurred_at'],
			'handled_by'      => $row['handled_by'],
			'reason'          => (string) ( $row['reason'] ?? '' ),
		] );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write.
		$result = $wpdb->update(
			$this->table(),
			[
				'deleted_at' => current_time( 'mysql', true ),
				'deleted_by' => $user_id,
			],
			[ 'id' => $id ]
		);

		if ( false === $result ) {
			return new \WP_Error( 'db_update_error', __( 'Failed to cancel the settlement record.', 'conferencemanager' ), $wpdb->last_error );
		}

		/**
		 * 精算明細の取り消し後に発火
		 *
		 * @since 1.6.5
		 * @param int   $id         明細ID
		 * @param int   $payment_id 決済ID
		 * @param array $adjustment 取り消した明細の内容
		 */
		do_action( 'conf_manager_payment_adjustment_deleted', $id, (int) $row['payment_id'], $row );

		return true;
	}

	/**
	 * 複数決済分の精算合計をまとめて取得する（一覧の N+1 回避）
	 *
	 * @param int[] $payment_ids 決済IDの配列
	 * @return array<int, array{refund:int, surcharge:int}> payment_id をキーとする合計
	 */
	public function get_totals_for_payments( array $payment_ids ): array {
		global $wpdb;

		$ids = array_values( array_unique( array_filter( array_map( 'absint', $payment_ids ) ) ) );
		if ( empty( $ids ) ) {
			return [];
		}

		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

		// 取り消した明細は数えない（集計 4 箇所すべてで同じ条件を通す。M-15 が見張る）。
		$effective = self::EFFECTIVE_SQL;

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- reason: テーブル名は内部メソッド由来、IN 句のプレースホルダは件数分を動的生成して prepare に渡している。
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT payment_id,
						COALESCE(SUM(CASE WHEN type = 'refund' THEN amount ELSE 0 END), 0) AS refund_total,
						COALESCE(SUM(CASE WHEN type = 'surcharge' THEN amount ELSE 0 END), 0) AS surcharge_total
				 FROM {$this->table()}
				 WHERE payment_id IN ({$placeholders}) AND {$effective}
				 GROUP BY payment_id",
				...$ids
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		$totals = [];
		foreach ( $rows ?: [] as $row ) {
			$totals[ (int) $row['payment_id'] ] = [
				'refund'    => (int) $row['refund_total'],
				'surcharge' => (int) $row['surcharge_total'],
			];
		}

		return $totals;
	}

	/**
	 * 決済 1 件分の精算合計を取得する
	 *
	 * @param int $payment_id 決済ID
	 * @return array{refund:int, surcharge:int}
	 */
	public function get_totals( int $payment_id ): array {
		$totals = $this->get_totals_for_payments( [ $payment_id ] );
		return $totals[ $payment_id ] ?? [ 'refund' => 0, 'surcharge' => 0 ];
	}

	/**
	 * 純入金額（実際に手元に残っている受領額）を求める
	 *
	 * @param array $payment 決済レコード
	 * @return int
	 */
	public function get_net_paid( array $payment ): int {
		$paid = PaymentManager::resolve_paid_amount( $payment );
		if ( null === $paid ) {
			return 0;
		}
		$totals = $this->get_totals( (int) $payment['id'] );
		return $paid - $totals['refund'] + $totals['surcharge'];
	}

	private function format_row( array $row ): array {
		$row['id']              = (int) $row['id'];
		$row['payment_id']      = (int) $row['payment_id'];
		$row['registration_id'] = (int) $row['registration_id'];
		$row['amount']          = (int) $row['amount'];
		$row['handled_by']      = null === $row['handled_by'] ? null : (int) $row['handled_by'];
		$row['deleted_at']      = $row['deleted_at'] ?? null;
		$row['deleted_by']      = empty( $row['deleted_by'] ) ? null : (int) $row['deleted_by'];

		$user                 = $row['handled_by'] ? get_userdata( $row['handled_by'] ) : null;
		$row['handled_by_name'] = $user ? $user->display_name : '';

		return $row;
	}
}
