/*******************************************************************************
 * Copyright (C) 2026, Leo Galambos
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its contributors
 *    may be used to endorse or promote products derived from this software
 *    without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 ******************************************************************************/
package org.egothor.stemmer;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.stream.Stream;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

/**
 * Unit tests for {@link PatchCommandEncoder}.
 *
 * <p>
 * The suite verifies both major public responsibilities of the encoder:
 * generation of compact patch commands and application of those commands back
 * to source terms.
 * </p>
 *
 * <p>
 * The implementation intentionally exposes some historical compatibility
 * behavior, especially when malformed patch commands cause index-related
 * failures during patch application. Those cases are covered explicitly so that
 * future refactoring does not silently alter externally observable semantics.
 * </p>
 */
@DisplayName("PatchCommandEncoder")
@Tag("unit")
@Tag("stemmer")
@Tag("patch")
@Tag("encoding")
@Tag("apply")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@SuppressWarnings("deprecation")
class PatchCommandEncoderTest {

    /**
     * Provides representative source-target pairs for round-trip validation.
     *
     * @return test arguments
     */
    private static Stream<Arguments> provideRoundTripPairs() {
        return Stream.of(
                // 1
                Arguments.of(1, "", ""),
                // 2
                Arguments.of(2, "a", "a"),
                // 3
                Arguments.of(3, "a", "b"),
                // 4
                Arguments.of(4, "ab", "ab"),
                // 5
                Arguments.of(5, "ab", "abc"),
                // 6
                Arguments.of(6, "abc", "ab"),
                // 7
                Arguments.of(7, "teacher", "teach"),
                // 8
                Arguments.of(8, "running", "run"),
                // 9
                Arguments.of(9, "cities", "city"),
                // 10
                Arguments.of(10, "walked", "walk"),
                // 11
                Arguments.of(11, "redo", "undo"),
                // 12
                Arguments.of(12, "stemming", "stem"),
                // 13
                Arguments.of(13, "abcdef", "azced"),
                // 14
                Arguments.of(14, "x", ""),
                // 15
                Arguments.of(15, "mississippi", "missouri"),
                // 16
                Arguments.of(16, "transformation", "transform"),
                // 17
                Arguments.of(17, "preprocessing", "process"),
                // 18
                Arguments.of(18, "internationalization", "i18n"),
                // 19
                Arguments.of(19, "bookkeeper", "bookkeeping"));
    }

    /**
     * Provides explicit patch application cases.
     *
     * @return test arguments
     */
    private static Stream<Arguments> provideApplyCases() {
        return Stream.of(
                // 1
                Arguments.of(1, "teacher", "Db", "teach"),
                // 2
                Arguments.of(2, "abc", "Ic", "abcc"),
                // 3
                Arguments.of(3, "abc", "Rx", "abx"),
                // 4
                Arguments.of(4, "abc", "-bRx", "xbc"),
                // 5
                Arguments.of(5, "abcd", "Dc", "a"),
                // 6
                Arguments.of(6, "abcd", "-c", "abcd"),
                // 7
                Arguments.of(7, "kitten", "DbIg", "kittg"),
                // 8
                Arguments.of(8, "", "Ix", "x"),
                // 9
                Arguments.of(9, "", "IbIa", "ab"),
                // 10
                Arguments.of(10, "teacher", PatchCommandEncoder.NOOP_PATCH, "teacher"));
    }

    /**
     * Provides explicit forward-direction single-instruction patch application cases.
     *
     * @return test arguments
     */
    private static Stream<Arguments> provideForwardSingleInstructionApplyCases() {
        return Stream.of(
                // 1
                Arguments.of(1, "abcd", "Db", "cd"),
                // 2
                Arguments.of(2, "abc", "Ia", "aabc"),
                // 3
                Arguments.of(3, "abc", "Ra", "abc"),
                // 4
                Arguments.of(4, "abc", "-a", "abc"),
                // 5
                Arguments.of(5, "abc", PatchCommandEncoder.NOOP_PATCH, "abc"));
    }

    /**
     * Provides forward-direction applyTo cases that exercise preserve-only and
     * non-preserve branches.
     *
     * @return test arguments
     */
    private static Stream<Arguments> provideForwardApplyToCases() {
        return Stream.of(
                // 1
                Arguments.of(1, "book", "-aRa", "baok"),
                // 2
                Arguments.of(2, "abc", "-dRa", "abc"),
                // 3
                Arguments.of(3, "abc", "DdRa", "abc"),
                // 4
                Arguments.of(4, "abc", "-dIa", "abc"),
                // 5
                Arguments.of(5, "abc", "Na-a", "abc"));
    }

    /**
     * Provides empty-source forward applyTo cases that cover empty-source
     * instruction handling.
     *
     * @return test arguments
     */
    private static Stream<Arguments> provideForwardEmptySourceApplyCases() {
        return Stream.of(
                // 1
                Arguments.of(1, "IaIb", "ab"),
                // 2
                Arguments.of(2, "-aRa", ""),
                // 3
                Arguments.of(3, "IaRa", ""),
                // 4
                Arguments.of(4, "Na-a", ""));
    }

    /**
     * Provides malformed or index-invalid patch inputs that must preserve the
     * original source according to the implementation contract.
     *
     * @return test arguments
     */
    private static Stream<Arguments> provideMalformedPatchCases() {
        return Stream.of(
                // 1
                Arguments.of(1, "abc", "Dz"),
                // 2
                Arguments.of(2, "abc", "-z"),
                // 3
                Arguments.of(3, "abc", "R"),
                // 4
                Arguments.of(4, "abc", "I"),
                // 5
                Arguments.of(5, "abc", "D"),
                // 6
                Arguments.of(6, "abc", "-"),
                // 7
                Arguments.of(7, "abc", "IuDz"),
                // 8
                Arguments.of(8, "", "Da"),
                // 9
                Arguments.of(9, "", "-a"),
                // 10
                Arguments.of(10, "", "Ra"),
                // 11
                Arguments.of(11, "abc", "D`"),
                // 12
                Arguments.of(12, "abc", "-`"),
                // 13
                Arguments.of(13, "", "D`"));
    }

    /**
     * Provides representative source-target pairs for mirrored-orientation tests.
     *
     * @return test arguments
     */
    private static Stream<Arguments> provideReversedRoundTripPairs() {
        return Stream.of(
                // 1
                Arguments.of(1, "", ""),
                // 2
                Arguments.of(2, "a", "a"),
                // 3
                Arguments.of(3, "a", "b"),
                // 4
                Arguments.of(4, "teacher", "teach"),
                // 5
                Arguments.of(5, "running", "run"),
                // 6
                Arguments.of(6, "cities", "city"),
                // 7
                Arguments.of(7, "walked", "walk"),
                // 8
                Arguments.of(8, "redo", "undo"),
                // 9
                Arguments.of(9, "stemming", "stem"),
                // 10
                Arguments.of(10, "abcdef", "azced"),
                // 11
                Arguments.of(11, "mississippi", "missouri"),
                // 12
                Arguments.of(12, "transformation", "transform"),
                // 13
                Arguments.of(13, "preprocessing", "process"),
                // 14
                Arguments.of(14, "bookkeeper", "bookkeeping"),
                // 15
                Arguments.of(15, "", "x"),
                // 16
                Arguments.of(16, "", "ab"),
                // 17
                Arguments.of(17, "", "stem"));
    }

    /**
     * Returns a reversed copy of the supplied text.
     *
     * @param text input text
     * @return reversed text
     */
    private static String reverse(String text) {
        return new StringBuilder(text).reverse().toString();
    }

    /**
     * Applies a patch into a right-sized output buffer and returns the produced
     * string.
     *
     * @param source             source text
     * @param patch              patch command
     * @param traversalDirection traversal direction
     * @return transformed text
     */
    private static String applyToString(final String source, final String patch,
            final WordTraversalDirection traversalDirection) {
        final char[] output = new char[Math.max(source.length() + 16, 16)];
        final int produced = PatchCommandEncoder.applyTo(source, patch, traversalDirection, output, 0, output.length);
        return new String(output, 0, produced);
    }

    /**
     * Tests constructor validation and basic instantiation behavior.
     */
    @Nested
    @DisplayName("construction")
    @Tag("construction")
    @Tag("unit")
    @Tag("stemmer")
    @Tag("patch")
    class ConstructionTests {

        /**
         * Verifies that the default constructor creates a usable encoder instance.
         */
        @Test
        @DisplayName("creates encoder with default cost model")
        void shouldCreateEncoderWithDefaultCostModel() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            assertNotNull(encoder);
            assertEquals("teach", PatchCommandEncoder.apply("teacher", encoder.encode("teacher", "teach")));
        }

        /**
         * Verifies fluent builder construction with explicit forward traversal.
         */
        @Test
        @DisplayName("builds direction-specialized encoder via builder")
        void shouldBuildDirectionSpecializedEncoderViaBuilder() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder()
                    .traversalDirection(WordTraversalDirection.FORWARD)
                    .build();

            String patch = encoder.encode("running", "run");

            assertAll(() -> assertNotNull(encoder), () -> assertNotNull(patch),
                    () -> assertEquals("run", encoder.applyWithConfiguredDirection("running", patch)));
        }

        /**
         * Verifies that a negative insert cost is rejected.
         */
        @Test
        @DisplayName("rejects negative insert cost")
        void shouldRejectNegativeInsertCost() {
            IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                    () -> PatchCommandEncoder.builder().insertCost(-1).deleteCost(1).replaceCost(1).matchCost(0).build());

            assertEquals("insertCost must be non-negative.", exception.getMessage());
        }

        /**
         * Verifies that a negative delete cost is rejected.
         */
        @Test
        @DisplayName("rejects negative delete cost")
        void shouldRejectNegativeDeleteCost() {
            IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                    () -> PatchCommandEncoder.builder().insertCost(1).deleteCost(-1).replaceCost(1).matchCost(0).build());

            assertEquals("deleteCost must be non-negative.", exception.getMessage());
        }

        /**
         * Verifies that a negative replace cost is rejected.
         */
        @Test
        @DisplayName("rejects negative replace cost")
        void shouldRejectNegativeReplaceCost() {
            IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                    () -> PatchCommandEncoder.builder().insertCost(1).deleteCost(1).replaceCost(-1).matchCost(0).build());

            assertEquals("replaceCost must be non-negative.", exception.getMessage());
        }

        /**
         * Verifies that a negative match cost is rejected.
         */
        @Test
        @DisplayName("rejects negative match cost")
        void shouldRejectNegativeMatchCost() {
            IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                    () -> PatchCommandEncoder.builder().insertCost(1).deleteCost(1).replaceCost(1).matchCost(-1).build());

            assertEquals("matchCost must be non-negative.", exception.getMessage());
        }
    }

    /**
     * Tests {@link PatchCommandEncoder#encode(String, String)}.
     */
    @Nested
    @DisplayName("encode(String, String)")
    @Tag("encoding")
    @Tag("unit")
    @Tag("stemmer")
    @Tag("patch")
    class EncodeTests {

        /**
         * Verifies that trailing SKIP instructions are omitted from the generated patch
         * command because they do not affect reconstruction.
         */
        @Test
        @DisplayName("does not emit trailing SKIP instructions into patch command")
        void shouldNotEmitTrailingSkipInstructionsIntoPatchCommand() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode("abcd", "ab");

            assertAll(() -> assertNotNull(patch), () -> assertEquals("Db", patch),
                    () -> assertEquals("ab", PatchCommandEncoder.apply("abcd", patch)), () -> assertEquals(-1,
                            patch.indexOf('-'), () -> "Patch must not contain a trailing SKIP instruction: " + patch));
        }

        /**
         * Verifies that a null source yields a null patch.
         */
        @Test
        @DisplayName("returns null when source is null")
        void shouldReturnNullWhenSourceIsNull() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode(null, "target");

            assertNull(patch);
        }

        /**
         * Verifies that a null target yields a null patch.
         */
        @Test
        @DisplayName("returns null when target is null")
        void shouldReturnNullWhenTargetIsNull() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode("source", null);

            assertNull(patch);
        }

        /**
         * Verifies that equal words always produce the canonical identity patch.
         */
        @Test
        @DisplayName("returns canonical NOOP patch for equal words")
        void shouldReturnCanonicalNoopPatchForEqualWords() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode("teacher", "teacher");

            assertAll(() -> assertNotNull(patch), () -> assertEquals(PatchCommandEncoder.NOOP_PATCH, patch),
                    () -> assertEquals("teacher", PatchCommandEncoder.apply("teacher", patch)));
        }

        /**
         * Verifies deterministic identity encoding for empty words.
         */
        @Test
        @DisplayName("returns canonical NOOP patch for equal empty words")
        void shouldReturnCanonicalNoopPatchForEqualEmptyWords() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode("", "");

            assertAll(() -> assertEquals(PatchCommandEncoder.NOOP_PATCH, patch),
                    () -> assertEquals("", PatchCommandEncoder.apply("", patch)));
        }

        /**
         * Verifies round-trip reconstruction on representative pairs.
         *
         * @param caseId numeric case identifier
         * @param source source word
         * @param target target word
         */
        @ParameterizedTest(name = "[{index}] case {0}: {1} -> {2}")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideRoundTripPairs")
        @DisplayName("produces patches that reconstruct the target")
        void shouldReconstructTargetForRoundTripPairs(int caseId, String source, String target) {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode(source, target);
            String reconstructed = PatchCommandEncoder.apply(source, patch);

            assertAll(
                    () -> assertNotNull(patch,
                            () -> "Case " + caseId + " unexpectedly produced a null patch for source='" + source
                                    + "', target='" + target + "'."),
                    () -> assertEquals(target, reconstructed, () -> "Case " + caseId + " failed for source='" + source
                            + "', target='" + target + "', patch='" + patch + "'."));
        }

        /**
         * Verifies that one encoder instance remains correct across varying matrix
         * sizes.
         */
        @Test
        @DisplayName("remains correct when reused across different input sizes")
        void shouldRemainCorrectWhenReusedAcrossDifferentInputSizes() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            assertAll(
                    () -> assertEquals("transformation",
                            PatchCommandEncoder.apply("transform", encoder.encode("transform", "transformation"))),
                    () -> assertEquals("cat", PatchCommandEncoder.apply("cats", encoder.encode("cats", "cat"))),
                    () -> assertEquals("book", PatchCommandEncoder.apply("back", encoder.encode("back", "book"))),
                    () -> assertEquals("", PatchCommandEncoder.apply("x", encoder.encode("x", ""))));
        }

        /**
         * Verifies that custom operation costs still produce a usable patch.
         */
        @Test
        @DisplayName("supports custom operation costs")
        void shouldSupportCustomOperationCosts() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().insertCost(1).deleteCost(1).replaceCost(2).matchCost(0).build();

            String patch = encoder.encode("teacher", "teach");
            String reconstructed = PatchCommandEncoder.apply("teacher", patch);

            assertAll(() -> assertNotNull(patch), () -> assertEquals("teach", reconstructed));
        }
    }

    /**
     * Tests {@link PatchCommandEncoder#apply(String, String)}.
     */
    @Nested
    @DisplayName("apply(String, String)")
    @Tag("apply")
    @Tag("unit")
    @Tag("stemmer")
    @Tag("patch")
    class ApplyTests {

        /**
         * Verifies that a null source returns null.
         */
        @Test
        @DisplayName("returns null when source is null")
        void shouldReturnNullWhenSourceIsNull() {
            assertNull(PatchCommandEncoder.apply(null, "Da"));
        }

        /**
         * Verifies that a null patch returns the original source.
         */
        @Test
        @DisplayName("returns original source when patch is null")
        void shouldReturnSourceWhenPatchIsNull() {
            String source = "teacher";

            assertSame(source, PatchCommandEncoder.apply(source, null));
        }

        /**
         * Verifies that an empty patch returns the original source.
         */
        @Test
        @DisplayName("returns original source when patch is empty")
        void shouldReturnSourceWhenPatchIsEmpty() {
            String source = "teacher";

            assertSame(source, PatchCommandEncoder.apply(source, ""));
        }

        /**
         * Verifies that the canonical NOOP patch returns the original source.
         */
        @Test
        @DisplayName("returns original source when patch is canonical NOOP")
        void shouldReturnSourceWhenPatchIsCanonicalNoop() {
            String source = "teacher";

            assertSame(source, PatchCommandEncoder.apply(source, PatchCommandEncoder.NOOP_PATCH));
        }

        /**
         * Verifies that instance-level application follows encoder traversal
         * direction.
         */
        @Test
        @DisplayName("applies patch via instance-level direction-specialized fast path")
        void shouldApplyPatchViaInstanceLevelDirectionSpecializedFastPath() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder()
                    .traversalDirection(WordTraversalDirection.FORWARD)
                    .build();

            String patch = encoder.encode("transformation", "transform");

            assertEquals("transform", encoder.applyWithConfiguredDirection("transformation", patch));
        }

        /**
         * Verifies dedicated forward traversal encode/apply round trip.
         */
        @Test
        @DisplayName("reconstructs target with forward traversal encoder and static apply")
        void shouldReconstructTargetWithForwardTraversalEncoderAndStaticApply() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder()
                    .traversalDirection(WordTraversalDirection.FORWARD)
                    .build();
            String patch = encoder.encode("cities", "city");

            assertEquals("city", PatchCommandEncoder.apply("cities", patch, WordTraversalDirection.FORWARD));
        }

        /**
         * Verifies explicit single-instruction forward patch application
         * semantics.
         *
         * @param caseId   numeric case identifier
         * @param source   source word
         * @param patch    encoded patch command
         * @param expected expected transformed word
         */
        @ParameterizedTest(name = "[{index}] case {0}: forward single instruction apply({1}, {2}) -> {3}")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideForwardSingleInstructionApplyCases")
        @DisplayName("applies forward single instruction patches correctly")
        void shouldApplyForwardSingleInstructionsExplicitly(int caseId, String source, String patch, String expected) {
            assertEquals(expected, PatchCommandEncoder.apply(source, patch, WordTraversalDirection.FORWARD),
                    () -> "Case " + caseId + " failed for source='" + source + "', patch='" + patch + "'.");
        }

        /**
         * Verifies forward single-instruction malformed commands fail fast.
         */
        @Test
        @DisplayName("throws for unsupported forward opcode and NOOP argument")
        void shouldThrowForUnsupportedForwardOpcodeAndNoopArgument() {
            assertAll(() -> assertEquals("Unsupported patch opcode: X",
                    assertThrows(IllegalArgumentException.class,
                            () -> PatchCommandEncoder.apply("abc", "Xa", WordTraversalDirection.FORWARD))
                                    .getMessage()),
                    () -> assertEquals("Unsupported NOOP patch argument: `",
                            assertThrows(IllegalArgumentException.class,
                                    () -> PatchCommandEncoder.apply("abc", "N`", WordTraversalDirection.FORWARD))
                                            .getMessage()));
        }

        /**
         * Verifies explicit forward-applyTo cases that exercise preserve-only and
         * non-preserve branches.
         *
         * @param caseId   numeric case identifier
         * @param source   source word
         * @param patch    encoded patch command
         * @param expected expected transformed word
         */
        @ParameterizedTest(name = "[{index}] case {0}: applyToForward({1}, {2}) -> {3}")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideForwardApplyToCases")
        @DisplayName("applyTo handles forward preserve-only and mutation branches")
        void shouldApplyToForwardPreserveAndMutationBranches(int caseId, String source, String patch, String expected) {
            final char[] output = new char[Math.max(source.length() + 16, 16)];

            final int produced = PatchCommandEncoder.applyTo(source, patch, WordTraversalDirection.FORWARD, output, 0,
                    output.length);

            assertAll(
                    () -> assertEquals(expected.length(), produced,
                            () -> "Case " + caseId + " produced wrong length."),
                    () -> assertEquals(expected, new String(output, 0, produced),
                            () -> "Case " + caseId + " failed for patch='" + patch + "'."));
        }

        /**
         * Verifies empty-source forward applyTo behavior for insert-only and malformed
         * instructions.
         *
         * @param caseId numeric case identifier
         * @param patch  encoded patch command
         * @param expected expected transformed word
         */
        @ParameterizedTest(name = "[{index}] case {0}: applyToForward(\"\", {1}) -> \"{2}\"")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideForwardEmptySourceApplyCases")
        @DisplayName("applies forward empty-source patches correctly")
        void shouldApplyToForwardEmptySourceCases(int caseId, String patch, String expected) {
            final char[] output = new char[Math.max(expected.length() + 16, 16)];

            final int produced = PatchCommandEncoder.applyTo("", patch, WordTraversalDirection.FORWARD, output, 0,
                    output.length);

            assertAll(
                    () -> assertEquals(expected.length(), produced,
                            () -> "Case " + caseId + " produced wrong length."),
                    () -> assertEquals(expected, new String(output, 0, produced),
                            () -> "Case " + caseId + " failed for patch='" + patch + "'."));
        }

        /**
         * Verifies malformed empty-source forward patches fail fast and preserve
         * empty-source semantics.
         */
        @Test
        @DisplayName("throws for unsupported NOOP argument on empty-source forward patch")
        void shouldThrowForUnsupportedNoopArgumentOnForwardEmptySource() {
            IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                    () -> PatchCommandEncoder.apply("", "N`Ra", WordTraversalDirection.FORWARD));

            assertEquals("Unsupported NOOP patch argument: `", exception.getMessage());
        }

        /**
         * Verifies explicit patch application cases.
         *
         * @param caseId   numeric case identifier
         * @param source   source word
         * @param patch    patch command
         * @param expected expected transformed word
         */
        @ParameterizedTest(name = "[{index}] case {0}: apply({1}, {2}) -> {3}")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideApplyCases")
        @DisplayName("applies explicit patch commands correctly")
        void shouldApplyExplicitPatchCommandsCorrectly(int caseId, String source, String patch, String expected) {
            assertEquals(expected, PatchCommandEncoder.apply(source, patch),
                    () -> "Case " + caseId + " failed for source='" + source + "', patch='" + patch + "'.");
        }

        /**
         * Verifies that unsupported opcodes fail fast.
         */
        @Test
        @DisplayName("throws IllegalArgumentException for unsupported opcode")
        void shouldThrowForUnsupportedOpcode() {
            IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                    () -> PatchCommandEncoder.apply("abc", "Xa"));

            assertEquals("Unsupported patch opcode: X", exception.getMessage());
        }

        /**
         * Verifies that an unsupported NOOP argument fails fast.
         */
        @Test
        @DisplayName("throws IllegalArgumentException for unsupported NOOP argument")
        void shouldThrowForUnsupportedNoopArgument() {
            IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                    () -> PatchCommandEncoder.apply("abc", "Nb"));

            assertEquals("Unsupported NOOP patch argument: b", exception.getMessage());
        }

        /**
         * Verifies malformed and index-invalid compatibility behavior.
         *
         * @param caseId         numeric case identifier
         * @param source         original source
         * @param malformedPatch malformed patch
         */
        @ParameterizedTest(name = "[{index}] case {0}: malformed patch {2} preserves {1}")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideMalformedPatchCases")
        @DisplayName("returns the original source for malformed or index-invalid patch commands")
        void shouldReturnOriginalSourceForMalformedOrIndexInvalidPatchCommands(int caseId, String source,
                String malformedPatch) {
            assertEquals(source, PatchCommandEncoder.apply(source, malformedPatch), () -> "Case " + caseId
                    + " failed for source='" + source + "', malformedPatch='" + malformedPatch + "'.");
        }

        /**
         * Verifies buffer application against string-returning application.
         *
         * @param caseId   numeric case identifier
         * @param source   source word
         * @param patch    patch command
         * @param expected expected transformed word
         */
        @ParameterizedTest(name = "[{index}] case {0}: applyTo({1}, {2}) -> {3}")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideApplyCases")
        @DisplayName("applyTo matches apply for explicit backward patch commands")
        void shouldApplyToBufferLikeApplyForBackwardCommands(int caseId, String source, String patch, String expected) {
            final char[] output = "___..............".toCharArray();

            final int produced = PatchCommandEncoder.applyTo(source, patch, WordTraversalDirection.BACKWARD, output, 3,
                    output.length - 3);

            assertAll(() -> assertEquals(expected.length(), produced, () -> "Case " + caseId + " produced wrong length."),
                    () -> assertEquals(expected, new String(output, 3, produced)));
        }

        /**
         * Verifies char-array source slices.
         */
        @Test
        @DisplayName("applyTo supports char-array source slices")
        void shouldApplyToCharArraySourceSlice() {
            final char[] source = "__teacher__".toCharArray();
            final char[] output = new char[16];

            final int produced = PatchCommandEncoder.applyTo(source, 2, 7, "Db", WordTraversalDirection.BACKWARD,
                    output, 1, output.length - 1);

            assertAll(() -> assertEquals(5, produced), () -> assertEquals("teach", new String(output, 1, produced)));
        }

        /**
         * Verifies null and range validation for buffer application.
         */
        @Test
        @DisplayName("applyTo rejects null and invalid range arguments")
        void shouldRejectNullAndInvalidRangeArguments() {
            final char[] source = "teacher".toCharArray();
            final char[] output = new char[16];

            assertAll(() -> assertThrows(NullPointerException.class,
                    () -> PatchCommandEncoder.applyTo((CharSequence) null, "Db", WordTraversalDirection.BACKWARD,
                            output, 0, output.length)),
                    () -> assertThrows(NullPointerException.class,
                            () -> PatchCommandEncoder.applyTo("teacher", "Db", null, output, 0, output.length)),
                    () -> assertThrows(NullPointerException.class,
                            () -> PatchCommandEncoder.applyTo("teacher", "Db", WordTraversalDirection.BACKWARD, null,
                                    0, output.length)),
                    () -> assertThrows(IndexOutOfBoundsException.class,
                            () -> PatchCommandEncoder.applyTo("teacher", "Db", WordTraversalDirection.BACKWARD,
                                    output, -1, output.length)),
                    () -> assertThrows(NullPointerException.class,
                            () -> PatchCommandEncoder.applyTo((char[]) null, 0, 7, "Db",
                                    WordTraversalDirection.BACKWARD, output, 0, output.length)),
                    () -> assertThrows(IndexOutOfBoundsException.class,
                            () -> PatchCommandEncoder.applyTo(source, 1, source.length, "Db",
                                    WordTraversalDirection.BACKWARD, output, 0, output.length)));
        }

        /**
         * Verifies null, empty, and canonical NOOP patch preservation.
         */
        @Test
        @DisplayName("applyTo preserves source for null, empty, and canonical NOOP patches")
        void shouldApplyToPreserveSourceForEmptyCompatibilityPatches() {
            final char[] nullPatchOutput = new char[8];
            final char[] emptyPatchOutput = new char[8];
            final char[] noopOutput = new char[8];

            final int nullPatchLength = PatchCommandEncoder.applyTo("teacher", null, WordTraversalDirection.BACKWARD,
                    nullPatchOutput, 0, nullPatchOutput.length);
            final int emptyPatchLength = PatchCommandEncoder.applyTo("teacher", "", WordTraversalDirection.BACKWARD,
                    emptyPatchOutput, 0, emptyPatchOutput.length);
            final int noopLength = PatchCommandEncoder.applyTo("teacher", PatchCommandEncoder.NOOP_PATCH,
                    WordTraversalDirection.BACKWARD, noopOutput, 0, noopOutput.length);

            assertAll(() -> assertEquals(7, nullPatchLength),
                    () -> assertEquals("teacher", new String(nullPatchOutput, 0, nullPatchLength)),
                    () -> assertEquals(7, emptyPatchLength),
                    () -> assertEquals("teacher", new String(emptyPatchOutput, 0, emptyPatchLength)),
                    () -> assertEquals(7, noopLength),
                    () -> assertEquals("teacher", new String(noopOutput, 0, noopLength)));
        }

        /**
         * Verifies insufficient capacity behavior.
         */
        @Test
        @DisplayName("applyTo reports insufficient capacity without writing output")
        void shouldReportInsufficientCapacityWithoutWritingOutput() {
            final char[] output = "xxxx".toCharArray();

            final int produced = PatchCommandEncoder.applyTo("abc", "Ic", WordTraversalDirection.BACKWARD, output, 0,
                    output.length - 1);

            assertAll(() -> assertEquals(PatchCommandEncoder.APPLY_INSUFFICIENT_CAPACITY, produced),
                    () -> assertArrayEquals("xxxx".toCharArray(), output));
        }

        /**
         * Verifies exception parity with string-returning application.
         */
        @Test
        @DisplayName("applyTo throws for unsupported opcode and NOOP argument")
        void shouldApplyToThrowForUnsupportedOpcodeAndNoopArgument() {
            final char[] output = new char[8];

            assertAll(() -> {
                final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                        () -> PatchCommandEncoder.applyTo("abc", "Xa", WordTraversalDirection.BACKWARD, output, 0,
                                output.length));
                assertEquals("Unsupported patch opcode: X", exception.getMessage());
            }, () -> {
                final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                        () -> PatchCommandEncoder.applyTo("abc", "Nb", WordTraversalDirection.BACKWARD, output, 0,
                                output.length));
                assertEquals("Unsupported NOOP patch argument: b", exception.getMessage());
            });
        }

        /**
         * Verifies malformed compatibility behavior for buffer application.
         *
         * @param caseId         numeric case identifier
         * @param source         original source
         * @param malformedPatch malformed patch
         */
        @ParameterizedTest(name = "[{index}] case {0}: malformed applyTo patch {2} preserves {1}")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideMalformedPatchCases")
        @DisplayName("applyTo preserves source for malformed or index-invalid patch commands")
        void shouldApplyToPreserveSourceForMalformedOrIndexInvalidPatchCommands(int caseId, String source,
                String malformedPatch) {
            final char[] output = new char[Math.max(source.length(), 1)];

            final int produced = PatchCommandEncoder.applyTo(source, malformedPatch, WordTraversalDirection.BACKWARD,
                    output, 0, output.length);

            assertAll(() -> assertEquals(source.length(), produced, () -> "Case " + caseId + " produced wrong length."),
                    () -> assertEquals(source, new String(output, 0, produced)));
        }

        /**
         * Verifies explicit traversal direction for buffer application.
         */
        @Test
        @DisplayName("applyTo follows explicit forward traversal direction")
        void shouldApplyToWithForwardTraversalDirection() {
            final PatchCommandEncoder encoder = PatchCommandEncoder.builder()
                    .traversalDirection(WordTraversalDirection.FORWARD)
                    .build();
            final String patch = encoder.encode("cities", "city");

            assertEquals(PatchCommandEncoder.apply("cities", patch, WordTraversalDirection.FORWARD),
                    applyToString("cities", patch, WordTraversalDirection.FORWARD));
        }

        /**
         * Verifies overlapping source/output slices are rejected.
         */
        @Test
        @DisplayName("applyTo rejects overlapping char-array source and output ranges")
        void shouldRejectOverlappingSourceAndOutputRanges() {
            final char[] buffer = "teacher....".toCharArray();

            final IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                    () -> PatchCommandEncoder.applyTo(buffer, 0, 7, "Db", WordTraversalDirection.BACKWARD, buffer, 2, 5));

            assertEquals("source and output ranges must not overlap.", exception.getMessage());
        }
    }

    /**
     * Tests representative stemming-style scenarios.
     */
    @Nested
    @DisplayName("stemming-oriented scenarios")
    @Tag("regression")
    @Tag("unit")
    @Tag("stemmer")
    @Tag("patch")
    class StemmingScenarioTests {

        /**
         * Verifies deletion-heavy suffix stripping.
         */
        @Test
        @DisplayName("handles deletion-heavy suffix stripping")
        void shouldHandleDeletionHeavySuffixStripping() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode("teacher", "teach");

            assertEquals("teach", PatchCommandEncoder.apply("teacher", patch));
        }

        /**
         * Verifies plural to singular transformation.
         */
        @Test
        @DisplayName("handles plural to singular transformation")
        void shouldHandlePluralToSingularTransformation() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode("cities", "city");

            assertEquals("city", PatchCommandEncoder.apply("cities", patch));
        }

        /**
         * Verifies reduction to a shorter derivational stem.
         */
        @Test
        @DisplayName("handles derivational reduction to a shorter stem")
        void shouldHandleDerivationalReductionToShorterStem() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode("stemming", "stem");

            assertEquals("stem", PatchCommandEncoder.apply("stemming", patch));
        }

        /**
         * Verifies single-character replacement.
         */
        @Test
        @DisplayName("handles single-character replacement")
        void shouldHandleSingleCharacterReplacement() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String patch = encoder.encode("a", "z");

            assertEquals("z", PatchCommandEncoder.apply("a", patch));
        }
    }

    /**
     * Tests reversed-word processing.
     */
    @Nested
    @DisplayName("reversed-word processing")
    @Tag("normalization")
    @Tag("unit")
    @Tag("stemmer")
    @Tag("patch")
    class ReversedWordProcessingTests {

        /**
         * Verifies reconstruction for reversed source and target pairs.
         *
         * @param caseId numeric case identifier
         * @param source source word
         * @param target target word
         */
        @ParameterizedTest(name = "[{index}] case {0}: reverse({1}) -> reverse({2})")
        @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideReversedRoundTripPairs")
        @DisplayName("reconstructs reversed targets from reversed sources")
        void shouldReconstructReversedTargetsFromReversedSources(int caseId, String source, String target) {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            String reversedSource = reverse(source);
            String reversedTarget = reverse(target);

            String patch = encoder.encode(reversedSource, reversedTarget);
            String reconstructed = PatchCommandEncoder.apply(reversedSource, patch);

            assertAll(
                    () -> assertNotNull(patch,
                            () -> "Case " + caseId + " unexpectedly produced a null patch for reversedSource='"
                                    + reversedSource + "', reversedTarget='" + reversedTarget + "'."),
                    () -> assertEquals(reversedTarget, reconstructed,
                            () -> "Case " + caseId + " failed for reversedSource='" + reversedSource
                                    + "', reversedTarget='" + reversedTarget + "', patch='" + patch + "'."));
        }

        /**
         * Verifies representative mirrored stemming transformations.
         */
        @Test
        @DisplayName("handles mirrored stemming transformations")
        void shouldHandleMirroredStemmingTransformations() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            assertAll(
                    () -> assertEquals(reverse("teach"),
                            PatchCommandEncoder.apply(reverse("teacher"),
                                    encoder.encode(reverse("teacher"), reverse("teach")))),
                    () -> assertEquals(reverse("run"),
                            PatchCommandEncoder.apply(reverse("running"),
                                    encoder.encode(reverse("running"), reverse("run")))),
                    () -> assertEquals(reverse("city"),
                            PatchCommandEncoder.apply(reverse("cities"),
                                    encoder.encode(reverse("cities"), reverse("city")))),
                    () -> assertEquals(reverse("walk"), PatchCommandEncoder.apply(reverse("walked"),
                            encoder.encode(reverse("walked"), reverse("walk")))));
        }

        /**
         * Verifies encoder reuse on reversed words of different sizes.
         */
        @Test
        @DisplayName("remains correct when reused on reversed words of different sizes")
        void shouldRemainCorrectWhenReusedOnReversedWordsOfDifferentSizes() {
            PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

            assertAll(
                    () -> assertEquals(reverse("transformation"),
                            PatchCommandEncoder.apply(reverse("transform"),
                                    encoder.encode(reverse("transform"), reverse("transformation")))),
                    () -> assertEquals(reverse("cat"),
                            PatchCommandEncoder.apply(reverse("cats"),
                                    encoder.encode(reverse("cats"), reverse("cat")))),
                    () -> assertEquals(reverse("book"),
                            PatchCommandEncoder.apply(reverse("back"),
                                    encoder.encode(reverse("back"), reverse("book")))),
                    () -> assertEquals("",
                            PatchCommandEncoder.apply(reverse("x"), encoder.encode(reverse("x"), reverse("")))));
        }
    }

    /**
     * Verifies correctness under mirrored input orientation.
     *
     * @param caseId numeric case identifier
     * @param source source word
     * @param target target word
     */
    @ParameterizedTest(name = "[{index}] case {0}: mirrored consistency for {1} -> {2}")
    @MethodSource("org.egothor.stemmer.PatchCommandEncoderTest#provideReversedRoundTripPairs")
    @Tag("normalization")
    @DisplayName("preserves correctness under mirrored input orientation")
    void shouldPreserveCorrectnessUnderMirroredInputOrientation(int caseId, String source, String target) {
        PatchCommandEncoder encoder = PatchCommandEncoder.builder().build();

        String normalPatch = encoder.encode(source, target);
        String normalResult = PatchCommandEncoder.apply(source, normalPatch);

        String reversedSource = reverse(source);
        String reversedTarget = reverse(target);
        String reversedPatch = encoder.encode(reversedSource, reversedTarget);
        String reversedResult = PatchCommandEncoder.apply(reversedSource, reversedPatch);

        assertAll(
                () -> assertEquals(target, normalResult,
                        () -> "Case " + caseId + " failed in normal orientation for source='" + source + "', target='"
                                + target + "', patch='" + normalPatch + "'."),
                () -> assertEquals(reversedTarget, reversedResult,
                        () -> "Case " + caseId + " failed in mirrored orientation for reversedSource='" + reversedSource
                                + "', reversedTarget='" + reversedTarget + "', patch='" + reversedPatch + "'."));
    }
}
