<?php
/**
 * Settings credential masking helper.
 *
 * Encapsulates the §6.3 表示方針（資格情報マスクの正本）of
 * `specifications/security-and-compliance.md`. That section
 * canonicalizes the rule that every UI / REST surface that
 * needs to expose a credential value MUST display the value
 * with only the trailing four characters in plaintext, the
 * remainder replaced by the bullet character `•`. Values
 * shorter than four characters are masked in their entirety.
 *
 * The §6.3 rule, copied verbatim:
 *
 *   - UI 表示は「**末尾 4 文字のみ平文、それ以外は `•` でマスク**」する。
 *     例: API Secret `ABCDEFGHIJKLMN1234` → `••••••••••••••1234`
 *   - 末尾 4 文字未満の短い値は全文マスクする。
 *   - 設定画面、診断画面、エラー表示、エクスポート、コピー操作の
 *     すべてで同じマスク規則を適用する。
 *   - 接続確認レスポンスは「設定済み / 未設定」「接続成功 / 失敗」
 *     のみ返し、値そのものを返さない。
 *
 * Edge case worth noting explicitly: a value whose length is
 * exactly 4 yields the full value verbatim (length - 4 = 0
 * mask characters, plus the trailing 4 plaintext characters).
 * That follows the literal reading of §6.3 (the "全文マスク"
 * branch is gated on `length < 4`, not `length <= 4`). Callers
 * who consider a 4-character value too short to display safely
 * are expected to enforce a higher minimum on the credential
 * grammar separately; this helper is a pure renderer of the
 * §6.3 rule.
 *
 * Length is measured in characters (Unicode code points), not
 * bytes. The helper uses `mb_strlen()` / `mb_substr()` so a
 * multibyte secret like `日本語ABCD` (7 characters, 13 UTF-8
 * bytes) is masked as 3 dots + 4 trailing characters, not
 * 9 dots + 4 trailing bytes that would split a multibyte
 * sequence.
 *
 * Per §6.4, credential values themselves MUST NOT be logged
 * or echoed; only the masked form crosses the controller
 * boundary. This class is the only place in the codebase
 * that names the mask character so a future change to the
 * mask glyph is a one-file change.
 *
 * @package Affilun\Application\Settings
 */

declare(strict_types=1);

namespace Affilun\Application\Settings;

/**
 * Static helper that applies the §6.3 credential mask rule.
 *
 * See the file-level docblock for the canonical §6.3 text and
 * the rationale for measuring length in characters rather than
 * bytes.
 */
final class SettingsCredentialMask {

	/**
	 * Mask glyph used by §6.3.
	 *
	 * The U+2022 BULLET character is the canonical placeholder
	 * named in the specification example
	 * (`••••••••••••••1234`).
	 */
	private const MASK_CHAR = '•';

	/**
	 * Apply the §6.3 mask rule to the supplied value.
	 *
	 * Returns the empty string when `$value` is null or empty.
	 * For values whose character length is strictly less than
	 * four, returns a string composed entirely of the mask
	 * glyph, one per source character. For values whose length
	 * is four or more, returns `length - 4` mask glyphs followed
	 * by the last four characters verbatim.
	 *
	 * Length is measured by `mb_strlen()` so multibyte sequences
	 * are treated as single characters; the trailing four
	 * characters are extracted by `mb_substr()` so the result
	 * never splits a multibyte sequence.
	 *
	 * @param string|null $value Raw credential value, or null.
	 *
	 * @return string Masked representation per §6.3.
	 */
	public static function mask( ?string $value ): string {
		if ( $value === null || $value === '' ) {
			return '';
		}

		$length = mb_strlen( $value );

		if ( $length < 4 ) {
			return str_repeat( self::MASK_CHAR, $length );
		}

		$tail = mb_substr( $value, $length - 4, 4 );

		return str_repeat( self::MASK_CHAR, $length - 4 ) . $tail;
	}

	/**
	 * Report whether the supplied string already carries the
	 * §6.3 mask glyph.
	 *
	 * Used by REST / UI surfaces that need to distinguish a
	 * round-tripped masked value (which the rotating-input
	 * editor of §6.3 treats as "no change") from a freshly
	 * supplied plaintext replacement.
	 *
	 * @param string $value Value to inspect.
	 */
	public static function isMasked( string $value ): bool {
		return strpos( $value, self::MASK_CHAR ) !== false;
	}
}
