package com.dangerousthings.aliro;

import javacard.framework.APDU;
import javacard.framework.Applet;
import javacard.framework.ISO7816;
import javacard.framework.ISOException;
import javacard.framework.JCSystem;
import javacard.framework.Util;

/**
 * Scaffold for the Aliro step-up phase (spec §8.4). Lives under
 * {@link AliroAids#STEP_UP}.
 *
 * <p>This first pass only answers SELECT with a minimal FCI and rejects
 * unknown commands cleanly. The actual step-up protocol — mdoc
 * SessionData framing (DeviceRequest/DeviceResponse CBOR with
 * Aliro-remapped integer keys per Tables 8-21/8-22), ENVELOPE/GET
 * RESPONSE command chaining, and a fresh AES-256-GCM session keyed by
 * StepUpSKDevice/StepUpSKReader derived per spec §8.4.3 — comes in
 * follow-up iterations.
 *
 * <p>When that work lands, this applet will need access to the
 * {@code StepUpSK} (a 32-byte session-bound value produced by
 * {@link AliroApplet}'s derivation in §8.3.1.13). Since Java Card
 * installs distinct instances per AID, that state will be exchanged via
 * a shared package-private holder (not yet implemented).
 *
 * <p><b>Implementation notes for the Step-Up milestone (see
 * {@code docs/plans/2026-06-07-step-up-implementation.md}):</b>
 * <ol>
 *   <li><b>Opt 1 — Reuse AliroGcm.</b> Don't allocate a second
 *       {@link AliroGcm} for Step-Up; reuse {@link AliroApplet}'s instance
 *       via {@code aesKey.setKey(stepUpSKDevice)} between phases. Saves
 *       ~368 B transient. ExpeditedSK and StepUpSK are distinct so the
 *       key/IV uniqueness invariant holds across rekey.</li>
 *   <li><b>Opt 2 — Structural CBOR only.</b> The Access Document is opaque
 *       transport bytes to us; the reader does field-level parsing. We only
 *       need a structural CBOR codec (major type + length boundaries) for
 *       the SessionData wrapper and to extract {@code IssuerAuth} for
 *       verification. Do not implement field-level mdoc parsing — wastes
 *       bytecode and RAM. CBOR encoding MUST be deterministic per RFC 8949
 *       §4.2.1 anywhere we emit.</li>
 *   <li><b>Opt 3 — Stream-encrypt during ENVELOPE emit.</b> Don't buffer
 *       the entire encrypted DeviceResponse then chunk it; pipe plaintext
 *       through GCM as we emit ENVELOPE response chunks. Reduces transient
 *       footprint AND minimizes plaintext residence in RAM.</li>
 *   <li><b>Opt 4 — IssuerAuth verify cached at personalization.</b>
 *       {@link CredentialStore#finalizeAccessDocument} runs the COSE_Sign1
 *       verify once at write time; the persistent
 *       {@code accessDocumentVerified} flag is checked here at read time.
 *       Saves ~100 ms per transaction. Trusts that the Credential Issuer
 *       trust anchor is fixed for the card's lifetime — true for DT's
 *       implantable target but document the limitation.</li>
 * </ol>
 *
 * <p>StepUpApplet handles {@code INS_EXCHANGE} (0xC9) directly: spec-conformant
 * readers route EXCHANGE here after the step-up AID SELECT per §10.2.
 */
public class StepUpApplet extends Applet {

    /** ISO 7816 CLA byte (0x00) for ISO-standardized commands. ENVELOPE
     *  (INS=0xC3) per ISO 7816 / Table 8-14 in the Aliro spec uses this CLA,
     *  not the Aliro-proprietary 0x80, because ENVELOPE is the standard
     *  ISO command for carrying chained data. */
    private static final byte CLA_ISO = (byte) 0x00;
    private static final byte CLA_PROPRIETARY = (byte) 0x80;
    /** EXCHANGE command per spec §8.3.3.5 / Table 8-14. The reader sends a
     *  Reader Status sub-event under this INS once the Step-Up AID is the
     *  active applet. */
    private static final byte INS_EXCHANGE = (byte) 0xC9;
    /** ENVELOPE command per ISO 7816 + spec §8.4 (Step-Up entry point). The
     *  X-CUBE-ALIRO firmware sends the encrypted mdoc DeviceRequest in the
     *  ENVELOPE body once the Step-Up AID is the active applet. */
    private static final byte INS_ENVELOPE = (byte) 0xC3;
    /** GET RESPONSE per ISO 7816-4 §7.6.1 — the reader pulls the rest of a
     *  chained response after the applet returns SW=61xx. */
    private static final byte INS_GET_RESPONSE = (byte) 0xC0;

    /** 12-byte AES-256-GCM IV layout (§8.3.1.8/9): 8B prefix + 4B counter. */
    private static final short GCM_TAG_LEN = 16;

    /** Per-chunk APDU outgoing window for the response chaining path. The
     *  T=0/T=1 short-APDU response buffer caps cleanly at this size — leaves
     *  the trailing 4 B headroom of the standard 261 B jcardsim/JC buffer
     *  for SW + framing. */
    private static final short CHUNK_LEN = 252;

    /** Response chaining buffer for DeviceResponse outputs > {@link #CHUNK_LEN}.
     *  Sized for 372 B canonical DeviceResponse + 16 B GCM tag = 388 B with
     *  headroom for future shape tweaks. */
    private static final short RESPONSE_BUFFER_LEN = 512;

    /** Holds StepUpSKDevice / StepUpSKReader and the two BE32 message
     *  counters, plus the IV-stamping math (spec §8.3.1.6/8/9 + §8.4.3).
     *  All four arrays inside are CLEAR_ON_DESELECT. */
    private final StepUpSession session;

    /** 32-byte scratch used only during {@link #select()} to stage the
     *  StepUpSK copied out of {@link SessionContext} before HKDF derives the
     *  two session keys. Wiped after derivation so the parked StepUpSK never
     *  outlives the call. */
    private final byte[] stepUpSKScratch;

    /** 12-byte scratch for the GCM IV: filled by {@link StepUpSession#readerIv}
     *  or {@link StepUpSession#deviceIv} before each en/decrypt. CLEAR_ON_DESELECT. */
    private final byte[] ivScratch;

    /** Transient plaintext sink for ENVELOPE DeviceRequest and EXCHANGE
     *  Reader Status request decrypts. Sized for the largest reasonable
     *  reader-supplied payload; CLEAR_ON_DESELECT so post-deselect there's
     *  no plaintext residue. */
    private final byte[] scratchPlaintext;
    private static final short SCRATCH_PLAINTEXT_LEN = 256;

    /** Transient single-slot flag: 1 once {@link #select()} successfully
     *  derived the Step-Up session keys (i.e. the SELECT found
     *  {@link SessionContext} armed). EXCHANGE / ENVELOPE handlers refuse to
     *  run if this is 0. CLEAR_ON_DESELECT, alongside the keys themselves. */
    private final byte[] sessionFlags;
    private static final short FLAG_KEYS_READY = 0;
    private static final short FLAGS_LEN = 1;

    /** Built-then-encrypted DeviceResponse staging area for the response
     *  chaining path. ENVELOPE writes the encrypted DeviceResponse into here,
     *  then sends one {@link #CHUNK_LEN}-sized chunk per APDU (the first
     *  inside the ENVELOPE reply, the rest pulled via GET RESPONSE). Reused
     *  during the build step as a scratch for the canonical-CBOR DeviceResponse
     *  plaintext too — encrypt-in-place isn't an option because the GCM tag
     *  appends after the ciphertext. CLEAR_ON_DESELECT. */
    private final byte[] responseBuffer;

    /** {@code [offset, remaining]} for the chaining drain — both reset at the
     *  start of every ENVELOPE. {@code remaining == 0} signals
     *  "no more bytes to ship": GET RESPONSE arriving in that state returns
     *  SW_CONDITIONS_NOT_SATISFIED. CLEAR_ON_DESELECT. */
    private final short[] responseState;
    private static final short STATE_OFF = 0;
    private static final short STATE_REMAINING = 1;

    /** 4-byte scratch the {@link DeviceRequestParser} writes its CBOR header
     *  argument into. Reused across calls — caller-supplied is the
     *  allocation-free convention StructuralCbor uses. CLEAR_ON_DESELECT. */
    private final byte[] parserScratch4;

    public static void install(byte[] bArray, short bOffset, byte bLength) {
        StepUpApplet applet = new StepUpApplet();
        if (bArray == null || bLength == 0) {
            applet.register();
        } else {
            applet.register(bArray, (short) (bOffset + 1), bArray[bOffset]);
        }
    }

    private StepUpApplet() {
        // CLEAR_ON_DESELECT: the next deselect (e.g. reader moves back to
        // EXPEDITED or pulls the field) zeroes the session keys, matching the
        // "fresh keys per Step-Up phase" invariant we'll need when ENVELOPE /
        // EXCHANGE handlers run AES-GCM.
        session = new StepUpSession();
        stepUpSKScratch = JCSystem.makeTransientByteArray(StepUpSession.SK_LEN, JCSystem.CLEAR_ON_DESELECT);
        ivScratch = JCSystem.makeTransientByteArray(StepUpSession.IV_LEN, JCSystem.CLEAR_ON_DESELECT);
        scratchPlaintext = JCSystem.makeTransientByteArray(SCRATCH_PLAINTEXT_LEN, JCSystem.CLEAR_ON_DESELECT);
        sessionFlags = JCSystem.makeTransientByteArray(FLAGS_LEN, JCSystem.CLEAR_ON_DESELECT);
        responseBuffer = JCSystem.makeTransientByteArray(RESPONSE_BUFFER_LEN, JCSystem.CLEAR_ON_DESELECT);
        responseState = JCSystem.makeTransientShortArray((short) 2, JCSystem.CLEAR_ON_DESELECT);
        parserScratch4 = JCSystem.makeTransientByteArray((short) 4, JCSystem.CLEAR_ON_DESELECT);
    }

    /**
     * SELECT entry point. If {@link SessionContext} is armed (i.e. AUTH1 on
     * {@link AliroApplet} just succeeded and parked {@code StepUpSK}), copy
     * it out and derive {@code StepUpSKDevice}/{@code StepUpSKReader} via
     * the §8.4.3 HKDF so the ENVELOPE / EXCHANGE handlers can AES-256-GCM
     * with them. Always returns true — an un-armed SELECT (e.g. a reader
     * that touches the Step-Up AID before AUTH1) still gets a successful
     * FCI; downstream handlers will reject commands that need a live session.
     */
    @Override
    public boolean select() {
        if (SessionContext.isStepUpArmed()) {
            SessionContext.copyStepUpSK(stepUpSKScratch, (short) 0);
            CryptoSingletons.getAliroCrypto().deriveStepUpSessionKeys(
                stepUpSKScratch, (short) 0,
                session.skDevice, (short) 0,
                session.skReader, (short) 0);
            // Wipe the staged StepUpSK — the derived keys are sufficient
            // from here on and we don't want the IKM lingering in transient.
            Util.arrayFillNonAtomic(stepUpSKScratch, (short) 0, StepUpSession.SK_LEN, (byte) 0);
            // Spec §8.4.3 -> mdoc [6] §9.1.1.5: session-bound counters
            // initialised to 0x00000001 on session entry, one per direction.
            // CLEAR_ON_DESELECT already zeroes them on each fresh select;
            // session.reset() rewrites explicitly so a Step-Up SELECT
            // mid-session (without a deselect in between) also starts both
            // counters at 1.
            session.reset();
            sessionFlags[FLAG_KEYS_READY] = (byte) 1;
        } else {
            sessionFlags[FLAG_KEYS_READY] = (byte) 0;
        }
        return true;
    }

    @Override
    public void process(APDU apdu) {
        if (selectingApplet()) {
            sendStepUpFci(apdu);
            return;
        }

        byte[] buf = apdu.getBuffer();
        byte cla = buf[ISO7816.OFFSET_CLA];
        byte ins = buf[ISO7816.OFFSET_INS];

        // INS-first dispatch: ENVELOPE is ISO-class (0x00) per ISO 7816 + spec
        // Table 8-14, EXCHANGE is Aliro-proprietary (0x80). Don't blanket
        // reject CLA=0x00 -- it's a legitimate ENVELOPE entry point.
        if (cla == CLA_ISO && ins == INS_ENVELOPE) {
            processEnvelope(apdu);
            return;
        }
        if (cla == CLA_ISO && ins == INS_GET_RESPONSE) {
            processGetResponse(apdu);
            return;
        }
        if (cla == CLA_PROPRIETARY && ins == INS_EXCHANGE) {
            processExchange(apdu);
            return;
        }
        if (cla != CLA_ISO && cla != CLA_PROPRIETARY) {
            ISOException.throwIt(ISO7816.SW_CLA_NOT_SUPPORTED);
        }
        ISOException.throwIt(ISO7816.SW_INS_NOT_SUPPORTED);
    }

    /**
     * EXCHANGE (CLA=0x80, INS=0xC9) handler: request validation + encrypted
     * Reader Status response.
     *
     * <p>Spec §8.3.3.5 / Tables 8-19 + 8-20. The reader sends
     * {@code encrypted_payload || authentication_tag} under
     * {@code StepUpSKReader} (IV {@code 0x00*8 || stepup_reader_counter},
     * §8.3.1.8), empty AAD. The decrypted plaintext is the Reader Status
     * sub-event REQUEST:
     * <pre>
     * sub_event_id : 1B   ; 0x01 = ReaderStatusRequest (M2 only supports this)
     * payload_len  : 1B
     * payload      : Lb   ; empty for 0x01
     * </pre>
     *
     * <p>The applet validates the structure, then emits a Reader Status
     * sub-event RESPONSE (Table 8-20) plaintext:
     * <pre>
     * sub_event_id : 1B   ; echoes 0x01
     * status       : 1B   ; 0x00 = OK
     * payload_len  : 1B   ; 0 for M2
     * </pre>
     * GCM-encrypted under {@code StepUpSKDevice} + device IV
     * ({@code 0x00*7 || 0x01 || stepup_device_counter}, §8.3.1.6). Ciphertext+
     * tag is 3 + 16 = 19 B — fits in one APDU, no chaining needed.
     */
    private void processExchange(APDU apdu) {
        if (sessionFlags[FLAG_KEYS_READY] == 0) {
            // SELECT hit StepUpApplet without an armed SessionContext (i.e.
            // no successful AUTH1 ran on AliroApplet first). Spec §8.4 says
            // the Step-Up phase is only entered post-AUTH1; reject cleanly.
            ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
        }

        short lc = apdu.setIncomingAndReceive();
        byte[] buf = apdu.getBuffer();
        short dataOff = apdu.getOffsetCdata();

        // Need at least the 16-byte tag.
        if (lc < GCM_TAG_LEN) {
            ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
        }
        short ptLen = (short) (lc - GCM_TAG_LEN);
        if (ptLen > SCRATCH_PLAINTEXT_LEN) {
            ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
        }

        // Build the IV: 8 zero bytes (reader→device prefix per §8.3.1.8) +
        // stepup_reader_counter, big-endian, in the trailing 4 bytes.
        session.readerIv(ivScratch, (short) 0);

        // Decrypt. AliroGcm.decrypt throws CryptoException on tag mismatch;
        // remap to a security SW so an attacker can't tell tag-mismatch from
        // any other failure mode.
        try {
            CryptoSingletons.getAliroGcm().decrypt(
                session.skReader, (short) 0,
                ivScratch, (short) 0,
                buf, dataOff, lc,
                scratchPlaintext, (short) 0);
        } catch (ISOException e) {
            throw e;
        } catch (Throwable t) {
            ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
        }

        // Spec §8.3.1.8: reader_counter <- reader_counter + 1 after use.
        // Advance now so an early exit from request validation still leaves
        // the counter at the post-decrypt value (the reader counter advances
        // on every successful decrypt regardless of whether the request
        // semantically validates).
        session.advanceReaderCounter();

        // Parse the request shape: [sub_event_id, payload_len, payload].
        // Need at least sub_event_id + payload_len = 2 bytes.
        if (ptLen < 2) {
            Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
            ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
        }
        byte subEventId = scratchPlaintext[0];
        short payloadLen = (short) (scratchPlaintext[1] & 0xFF);
        if (payloadLen != (short) (ptLen - 2)) {
            Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
            ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
        }
        // Only sub_event_id 0x01 (ReaderStatusRequest) is supported in M2.
        // M2 ignores the payload contents for 0x01 (just length-validated above).
        if (subEventId != (byte) 0x01) {
            Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);
            ISOException.throwIt(ISO7816.SW_DATA_INVALID);
        }

        // Wipe the request plaintext — content not needed past validation.
        Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);

        // Build the Reader Status response plaintext directly into the APDU
        // buffer at offset 0, then encrypt in place. AliroGcm.encrypt supports
        // out == pt at the same offset (CTR mode + appended tag).
        buf[0] = (byte) 0x01;   // sub_event_id (echoes request)
        buf[1] = (byte) 0x00;   // status = OK
        buf[2] = (byte) 0x00;   // payload_len = 0
        short respPtLen = 3;

        // Device-side IV: 0x00*7 || 0x01 || stepup_device_counter (§8.3.1.6).
        session.deviceIv(ivScratch, (short) 0);

        short ctLen = CryptoSingletons.getAliroGcm().encrypt(
            session.skDevice, (short) 0,
            ivScratch, (short) 0,
            buf, (short) 0, respPtLen,
            buf, (short) 0);
        // Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
        session.advanceDeviceCounter();

        apdu.setOutgoingAndSend((short) 0, ctLen);
    }

    /**
     * ENVELOPE (CLA=0x00, INS=0xC3) handler: emits the canonical-CBOR
     * DeviceResponse via ISO 7816 response chaining.
     *
     * <p>Pipeline:
     * <ol>
     *   <li>Decrypt the inbound payload under {@code StepUpSKReader} with
     *       reader-side IV {@code 0x0000000000000000 || stepup_reader_counter}
     *       (§8.3.1.9). Advance {@code stepup_reader_counter}.</li>
     *   <li>Structurally validate the recovered DeviceRequest via
     *       {@link DeviceRequestParser#validate} — propagates SW_DATA_INVALID
     *       (malformed CBOR / wrong shape) or SW_CONDITIONS_NOT_SATISFIED
     *       (unsupported version) unchanged.</li>
     *   <li>Build the canonical-CBOR DeviceResponse via
     *       {@link DeviceResponseBuilder#build} carrying the cached Access
     *       Document at {@code documents[0].issuerSigned.issuerAuth}.</li>
     *   <li>In-place GCM-encrypt the DeviceResponse plaintext under
     *       {@code StepUpSKDevice} with device-side IV
     *       {@code 0x00*7 || 0x01 || stepup_device_counter} (§8.3.1.6).
     *       AliroGcm supports {@code in == out} at the same offset (CTR mode +
     *       trailing tag), so the cleartext is overwritten on the encryption
     *       pass — no second buffer needed.</li>
     *   <li>Advance {@code stepup_device_counter} and ship the first chunk.
     *       For our standard 372 B AD wrapper the response is 388 B; we send
     *       the first {@link #CHUNK_LEN} bytes inline with SW=61xx and the
     *       reader pulls the rest via GET RESPONSE.</li>
     * </ol>
     */
    private void processEnvelope(APDU apdu) {
        if (sessionFlags[FLAG_KEYS_READY] == 0) {
            // SELECT-Step-Up landed without an armed SessionContext (i.e. no
            // successful AUTH1 on AliroApplet first). Spec §8.4 keeps Step-Up
            // strictly post-AUTH1; reject cleanly.
            ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
        }

        // Invalidate any in-flight chaining state from a previous ENVELOPE.
        // A new ENVELOPE always restarts the response stream.
        responseState[STATE_OFF] = 0;
        responseState[STATE_REMAINING] = 0;

        short lc = apdu.setIncomingAndReceive();
        byte[] buf = apdu.getBuffer();
        short dataOff = apdu.getOffsetCdata();

        // Need at least the 16-byte tag (zero-byte plaintext is degenerate
        // but spec-legal); reject anything that can't possibly carry a tag.
        if (lc < GCM_TAG_LEN) {
            ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
        }
        short ptLen = (short) (lc - GCM_TAG_LEN);
        if (ptLen > SCRATCH_PLAINTEXT_LEN) {
            ISOException.throwIt(ISO7816.SW_WRONG_LENGTH);
        }

        // Build the reader-side IV: 8 zero bytes (§8.3.1.9) + reader counter.
        session.readerIv(ivScratch, (short) 0);

        try {
            CryptoSingletons.getAliroGcm().decrypt(
                session.skReader, (short) 0,
                ivScratch, (short) 0,
                buf, dataOff, lc,
                scratchPlaintext, (short) 0);
        } catch (ISOException e) {
            throw e;
        } catch (Throwable t) {
            ISOException.throwIt(ISO7816.SW_SECURITY_STATUS_NOT_SATISFIED);
        }
        // Spec §8.3.1.9: reader_counter <- reader_counter + 1 after use.
        session.advanceReaderCounter();

        // Structural validation. Throws ISOException on malformed CBOR /
        // unsupported version — let it propagate; SW mapping is the parser's
        // responsibility.
        DeviceRequestParser.validate(
            scratchPlaintext, (short) 0, ptLen,
            parserScratch4, (short) 0);

        // Wipe the decrypted DeviceRequest — its content doesn't influence the
        // response (M2 reader profile asks for the single cached AD), and we
        // don't want plaintext loitering past CLEAR_ON_DESELECT.
        Util.arrayFillNonAtomic(scratchPlaintext, (short) 0, ptLen, (byte) 0);

        // Build the canonical-CBOR DeviceResponse into responseBuffer. The
        // cached AD is staged directly into the builder via copyAccessDocument;
        // CredentialStore.hasAccessDocument() is asserted first so a card that
        // somehow reached Step-Up without an AD doesn't synthesize an empty
        // issuerAuth that would crash downstream readers.
        CredentialStore store = CredentialStore.get();
        if (store == null || !store.hasAccessDocument()) {
            ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
        }
        short adLen = store.getAccessDocumentLen();
        // Stage the AD into responseBuffer past the wrapper's max footprint.
        // The wrapper is ~100 B for our fixed shape; staging AD at offset 128
        // gives the builder a clean target at offset 0 and the AD source at a
        // distinct, non-overlapping location.
        short adStage = 128;
        store.copyAccessDocument(responseBuffer, adStage, (short) 0, adLen);
        short respLen = DeviceResponseBuilder.build(
            responseBuffer, adStage, adLen,
            responseBuffer, (short) 0);

        // Build the device-side IV: 0x00*7 || 0x01 || device_counter (§8.3.1.6).
        session.deviceIv(ivScratch, (short) 0);

        // In-place encrypt: AliroGcm supports out == in at the same offset.
        // Ciphertext overwrites the plaintext; the 16 B tag appends after.
        short ctLen = CryptoSingletons.getAliroGcm().encrypt(
            session.skDevice, (short) 0,
            ivScratch, (short) 0,
            responseBuffer, (short) 0, respLen,
            responseBuffer, (short) 0);
        // Spec §8.3.1.6: device_counter <- device_counter + 1 after use.
        session.advanceDeviceCounter();

        // Ship the first chunk. Total ciphertext is 388 B for the standard
        // 272 B AD case; we send CHUNK_LEN bytes and signal more via SW=61xx
        // (handled below in sendChunk).
        responseState[STATE_OFF] = 0;
        responseState[STATE_REMAINING] = ctLen;
        sendChunk(apdu);
    }

    /**
     * GET RESPONSE (CLA=0x00, INS=0xC0) handler — drains the next chunk of an
     * outstanding chained DeviceResponse staged by {@link #processEnvelope}.
     * Returns SW_CONDITIONS_NOT_SATISFIED if no chain is in flight (per
     * ISO 7816-4: GET RESPONSE outside a chained transfer is illegal).
     */
    private void processGetResponse(APDU apdu) {
        if (responseState[STATE_REMAINING] <= 0) {
            ISOException.throwIt(ISO7816.SW_CONDITIONS_NOT_SATISFIED);
        }
        sendChunk(apdu);
    }

    /**
     * Copies up to {@link #CHUNK_LEN} bytes out of {@link #responseBuffer} into
     * the APDU buffer, advances the chaining offset, and either ends naturally
     * with SW=9000 (last chunk) or throws {@code 0x6100 | xx} where {@code xx}
     * is the {@code min(0xFF, remaining)} signal of how many bytes are still
     * available via the next GET RESPONSE.
     *
     * <p>The {@code 0x6100} branch follows ISO 7816-4 §5.1.4: SW1=0x61 means
     * "process completed normally, SW2 more bytes available". SW2=0x00 in
     * that protocol means "256 or more remain"; we cap at 0xFF before the OR.
     */
    private void sendChunk(APDU apdu) {
        short off = responseState[STATE_OFF];
        short remaining = responseState[STATE_REMAINING];
        short chunk = remaining > CHUNK_LEN ? CHUNK_LEN : remaining;

        Util.arrayCopyNonAtomic(responseBuffer, off, apdu.getBuffer(), (short) 0, chunk);
        responseState[STATE_OFF] = (short) (off + chunk);
        responseState[STATE_REMAINING] = (short) (remaining - chunk);

        apdu.setOutgoingAndSend((short) 0, chunk);

        short stillRemaining = responseState[STATE_REMAINING];
        if (stillRemaining > 0) {
            short xx = stillRemaining > (short) 0xFF ? (short) 0xFF : stillRemaining;
            ISOException.throwIt((short) (0x6100 | (xx & 0xFF)));
        }
    }

    /**
     * Minimal FCI for step-up SELECT. The spec (§10.2.1.2) allows the UD to
     * advertise supported APDU command/response sizes here; those get added
     * when we implement ENVELOPE/GET RESPONSE.
     *
     * <pre>
     * 6F L
     *   84 09 [step-up AID]
     * </pre>
     */
    private void sendStepUpFci(APDU apdu) {
        byte[] buf = apdu.getBuffer();
        short off = 0;
        buf[off++] = 0x6F;
        short outerLenPos = off++;

        buf[off++] = (byte) 0x84;
        buf[off++] = (byte) AliroAids.STEP_UP.length;
        off = Util.arrayCopyNonAtomic(AliroAids.STEP_UP, (short) 0,
                                      buf, off, (short) AliroAids.STEP_UP.length);

        buf[outerLenPos] = (byte) (off - 2);
        apdu.setOutgoingAndSend((short) 0, off);
    }

    // --- Testing hooks ------------------------------------------------------
    // Package-private accessors that let tests verify SELECT-time key
    // derivation without exposing the session keys to any production caller.
    // NOT for use outside the applet's own test module.

    void copyStepUpSKDeviceForTesting(byte[] out, short outOff) {
        Util.arrayCopyNonAtomic(session.skDevice, (short) 0, out, outOff, StepUpSession.SK_LEN);
    }

    void copyStepUpSKReaderForTesting(byte[] out, short outOff) {
        Util.arrayCopyNonAtomic(session.skReader, (short) 0, out, outOff, StepUpSession.SK_LEN);
    }
}
