#include "board.h"
#include "ui_state.h"
#include "voice_capture_limits.h"
#include "button_gesture.h"
#include "button_sample.h"
#include "shell_input_policy.h"
#include "approval_control.h"
#include "speech_control.h"
#include <atomic>
#include <cassert>
#include <cstdio>
#include <string>
#include <vector>
#include "agent_history.h"
#include "history_view.h"
#include "touch_gesture.h"
#include "touch_transform.h"
#include "cJSON.h"
using namespace wearable;
constexpr const char* TAG="host";
void ESP_LOGI(const char*,const char*,...) {}
InputModal s_input_modal=InputModal::NONE;
UiState s_ui;
ApprovalControl s_approval;ApprovalAction s_approval_action;
LoadedPreferences s_saved_preferences;
uint64_t s_ui_epoch=1,s_voice_serial=0,s_voice_started=0,s_feedback_until=0;
VoiceSendMode s_voice_send_mode=VoiceSendMode::Confirm;
std::string s_voice_context;
bool s_effort_settings=false,s_permission_settings=false;
unsigned session_picker_opens=0;
uint64_t test_now_us=0;
std::string s_settings_phase,s_settings_id,s_settings_target,s_preference_feedback,s_voice_id,s_voice_phase,s_modal_error;
void openAgentSessionPicker() {++session_picker_opens;s_settings_phase="NAV";}
void pin_message_voice_context() {}
void native_approval_response(const ApprovalAction&) {}
std::string s_read_screen,s_transcript;
SpeechMenu s_speech_menu;
bool s_speech_direct=false;
bool s_cloud_connected=false,s_message_visible=false;
// Overlay ownership: message, approval, any modal or active playback.
bool page_overlay_visible() {return s_message_visible || s_approval.modal() || s_input_modal!=InputModal::NONE;}
std::string s_message_title,s_message_body;
uint64_t s_message_until_ms=0;
void board_show_message(const char* title,const char* body) {
    s_message_visible=true;s_message_title=title?title:"";s_message_body=body?body:"";
}
uint64_t esp_timer_get_time() {return test_now_us;}
// Navigator capability negotiation used by Double B's guard.
struct {bool catalog=false,target_select=false,history=false;uint64_t generation=0;} s_nav;
struct {bool selected=true;const char* agent="pi";const char* session_id="s";const char* final_text="";} s_pi;
// Bounded history state copied from the production globals.
wearable::AgentHistoryCache s_history_cache;
wearable::HistoryScreen s_history_screen;
wearable::ReplyReader s_reply_reader;
wearable::AgentBrowse s_agent_browse;
wearable::HistoryView s_history_view=wearable::HistoryView::Closed;
std::string s_history_request,s_history_error;
bool s_history_loading=false;
unsigned history_requests=0;
void queue_control(cJSON*) {++history_requests;}
wearable::TouchGestureReducer s_touch_reducer;
wearable::TouchTransform s_touch_transform;
uint64_t s_touch_contact_epoch=0;
bool s_touch_contact_valid=false;
bool s_touch_virtual_long=false;bool s_touch_virtual_long_a=false;
bool s_touch_diag_top=false,s_touch_diag_bottom=false,s_touch_diag_left=false,s_touch_diag_right=false,s_touch_diag_center=false;
std::string js(cJSON* root,const char* key) {
    const auto* item=cJSON_GetObjectItemCaseSensitive(root,key);
    return cJSON_IsString(item)?item->valuestring:"";
}
std::atomic<bool> s_vibration_enabled{true};
struct QuickModel {std::string id,default_effort;std::vector<std::string> efforts;};
std::vector<QuickModel> s_models;
size_t s_model_index=0,s_effort_index=0;
board_button_t s_page_click=BOARD_BUTTON_NONE;
int s_page=0;
const char* page_name(int) {return "host";}
const char* modal_name(InputModal) {return "modal";}
const char* gesture_name(Gesture) {return "gesture";}
unsigned record_start=0,record_stop=0,saves=0,remote_requests=0,voice_send=0,voice_cancel=0;
unsigned speech_requests=0;
bool record_cancelled=false;
bool save_success=false;
struct {unsigned brightness=0;void setPanelBrightness(unsigned v) {brightness=v;}} s_display;
void lock_display() {}
void unlock_display() {}
void draw_status() {}
void log_modal_transition(InputModal,const char*) {}
void stop_speech() {}
bool audio_backend_record_start(uint64_t) {++record_start;return true;}
uint64_t audio_backend_record_started_ms(uint64_t) {return 1;}
void audio_backend_record_stop(bool cancelled) {++record_stop;record_cancelled=cancelled;}
std::string new_identity(uint64_t* serial=nullptr) {if(serial) *serial=17;return "test-id";}
std::string selected_target() {return "agent:pi:retained";}
void voice_command(const char*,const char* decision=nullptr) {if(decision && std::string(decision)=="send") ++voice_send;else ++voice_cancel;}
void speech_command(const char*) {++speech_requests;}
std::string read_screen_text() {return "screen";}
void settings_command(const char*) {++remote_requests;}
void cancel_input(bool,const char*) {
    s_reply_reader.clear();
    s_input_modal=InputModal::NONE;s_ui.close_settings();
    s_history_view=wearable::HistoryView::Closed;s_history_request.clear();s_history_loading=false;
    ++s_ui_epoch;
}
esp_err_t preferences_save(LoadedPreferences& saved,const DevicePreferences& value) {
    ++saves;if(save_success) {saved.value=value;return ESP_OK;}return ESP_FAIL;
}
void sync_display_page() {}
// Host stand-in for the font-dependent History layout. The production version
// wraps with the active font; here one screen holds 40 characters, which is
// enough to exercise multi-screen message traversal deterministically.
struct HistoryLayout { static constexpr int kTop=160,kBottom=360; size_t rows=1,screens=1; };
HistoryLayout history_layout(const std::string& body) {
    HistoryLayout layout;
    layout.rows=40;
    layout.screens=std::max<size_t>(1,(body.size()+layout.rows-1)/layout.rows);
    return layout;
}
// The touch helpers read the reducer and call the same reducer as the buttons.
// Host stand-in for the production Navigator reducer. It mirrors the real
// ownership guard and the one action that commits business state: a CLICK on a
// Native UserInput page emits a user_input_response. The numeric response is
// observable through native_responses, exactly like a queued control message.
unsigned native_responses=0;
bool navigator_owns_gesture() {
    return s_input_modal==InputModal::SETTINGS && s_settings_phase=="NAV";
}
bool navigator_gesture(bool a,Gesture g,uint64_t) {
    if(!navigator_owns_gesture()) return false;
    if(g==Gesture::LONG_RELEASE) return true;
    if(g==Gesture::LONG) return true;
    if(g!=Gesture::CLICK) return true;
    // A CLICK answers the pending Native UserInput question.
    ++native_responses;
    (void)a;
    return true;
}
void handle_gesture(bool a, Gesture g, uint64_t epoch, uint64_t now);
#include "shell_input.inc"
// Compose the same sampler used by the GPIO task with the production reducer.
void poll_shell_button(ButtonGesture &button,bool a,bool raw,uint64_t now) {
    const ButtonInputPolicy policy{s_ui_epoch+s_approval.generation,
        shell_long_press_allowed(s_input_modal,s_approval.modal()),
        shell_double_press_allowed(s_input_modal,s_approval.modal(),page_overlay_visible())};
    const auto event=sample_button(button,a,raw,now,policy);
    handle_gesture(a,event.gesture,event.epoch,event.sampled_ms);
}

void gesture(bool a,Gesture g) {handle_gesture(a,g,s_ui_epoch+s_approval.generation,1000);}
// Poll the extracted production GPIO -> policy -> ButtonGesture -> reducer.
// Two short taps on the SAME button inside DOUBLE_GAP_MS, at the raw GPIO level.
// When the Shell is eligible (no modal/approval/overlay) the first tap must not
// have queued a page CLICK; when a modal owns input the CLICK is immediate.
void raw_double(bool a, bool expect_no_click=true) {
    static uint64_t now=500000;
    ButtonGesture button;
    for(int tap=0;tap<2;++tap) {
        poll_shell_button(button,a,true,now);
        poll_shell_button(button,a,true,now+29);
        poll_shell_button(button,a,true,now+30);
        poll_shell_button(button,a,true,now+90);
        poll_shell_button(button,a,false,now+91);
        poll_shell_button(button,a,false,now+121);
        if(tap==0 && expect_no_click) assert(s_page_click==BOARD_BUTTON_NONE); // No first CLICK.
        now+=140;
    }
    now+=2000;
}
// One raw short tap; the CLICK is only released after the gap expires.
void raw_single(bool a) {
    static uint64_t now=700000;
    ButtonGesture button;
    poll_shell_button(button,a,true,now);
    poll_shell_button(button,a,true,now+30);
    poll_shell_button(button,a,false,now+60);
    poll_shell_button(button,a,false,now+91);
    assert(s_page_click==BOARD_BUTTON_NONE);            // Still pending, not yet.
    poll_shell_button(button,a,false,now+91+ButtonGesture::DOUBLE_GAP_MS+2);
    now+=3000;
}
// Return to a clean top-level Shell for the next shortcut case.
void top_level_shell() {
    s_reply_reader.clear();
    s_input_modal=InputModal::NONE;s_ui.close_settings();s_settings_phase.clear();
    s_message_visible=false;s_page_click=BOARD_BUTTON_NONE;s_speech_direct=false;
    s_approval.disconnect();
}
void double_shortcut_regression() {
    // Top-level Shell, no modal/approval/overlay: Double B uses the existing
    // openAgentSessionPicker entry and reaches the Navigator state machine.
    top_level_shell();
    s_cloud_connected=true;s_nav.catalog=true;s_nav.target_select=true;
    const auto opens=session_picker_opens;
    raw_double(false);
    assert(session_picker_opens==opens+1);
    assert(s_input_modal==InputModal::SETTINGS && s_settings_phase=="NAV");
    cancel_input(false,"host");top_level_shell();
    // Double B without catalog / target-select capability shows a bounded Error.
    s_cloud_connected=true;s_nav.catalog=false;s_nav.target_select=false;
    raw_double(false);
    assert(session_picker_opens==opens+1);               // No second picker.
    assert(s_input_modal==InputModal::ERROR && !s_modal_error.empty());
    cancel_input(false,"host");top_level_shell();
    // Double B offline also fails closed, never crashes.
    s_cloud_connected=false;s_nav.catalog=true;s_nav.target_select=true;
    raw_double(false);
    assert(session_picker_opens==opens+1 && s_input_modal==InputModal::ERROR);
    cancel_input(false,"host");top_level_shell();
    // Double A enters the Speech Mode page directly with A NEXT / B APPLY.
    s_cloud_connected=true;
    raw_double(true);
    assert(s_input_modal==InputModal::SETTINGS && s_settings_phase=="SPEECH");
    assert(s_speech_menu.page==SpeechMenu::Page::MODE && s_speech_direct);
    assert(std::string(s_speech_menu.label())=="OFF");
    gesture(true,Gesture::CLICK);
    assert(std::string(s_speech_menu.label())=="MANUAL");
    gesture(true,Gesture::CLICK);gesture(true,Gesture::CLICK);
    assert(std::string(s_speech_menu.label())=="AUTO_SUMMARY");
    const auto requests=speech_requests;
    gesture(false,Gesture::CLICK);                        // B APPLY
    assert(speech_requests==requests+1);
    assert(s_settings_phase=="SAVING SPEECH" && s_speech_direct);
    // The Gateway acknowledgement returns to the Shell with a SAVED notice.
    s_speech_menu.load("AUTO_SUMMARY");
    speech_mode_saved();
    assert(s_input_modal==InputModal::NONE && !s_ui.settings_open && !s_speech_direct);
    assert(s_message_visible && s_message_body.find("AUTO_SUMMARY")!=std::string::npos);
    assert(s_speech_menu.saved_mode==3);                   // AUTO_SUMMARY persisted.
    assert(s_speech_menu.page==SpeechMenu::Page::SPEECH);  // Playback still frozen.
    s_message_visible=false;
    // Double A offline: bounded Error, not a stuck modal.
    s_cloud_connected=false;
    raw_double(true);
    assert(s_input_modal==InputModal::ERROR);
    cancel_input(false,"host");s_cloud_connected=true;top_level_shell();
    // Overlay active: Double is disabled entirely.
    s_nav.catalog=true;s_nav.target_select=true;
    s_message_visible=true;s_page_click=BOARD_BUTTON_NONE;
    const auto overlay_opens=session_picker_opens;
    raw_double(false,false);
    assert(session_picker_opens==overlay_opens && s_input_modal==InputModal::NONE);
    s_message_visible=false;
    // Modal active: Double stays disabled and single CLICKs remain immediate.
    top_level_shell();s_input_modal=InputModal::SETTINGS;s_settings_phase="MENU";s_ui.open_settings();
    const auto modal_opens=session_picker_opens;const auto modal_speech=speech_requests;
    s_page_click=BOARD_BUTTON_NONE;
    raw_double(false,false);raw_double(true,false);
    assert(session_picker_opens==modal_opens && speech_requests==modal_speech);
    cancel_input(false,"host");top_level_shell();
    // Approval active: Double stays disabled; A/B remain immediate single clicks.
    s_approval.receive({"dbl-approval","pi","command","details",0},1000);
    s_approval_action={};
    const auto approval_opens=session_picker_opens;
    // The first tap of an A double resolves the approval immediately (~0 ms),
    // so no 300 ms wait and no double ever reaches the Shell.
    ButtonGesture approval;
    poll_shell_button(approval,true,true,910000);
    poll_shell_button(approval,true,true,910030);
    poll_shell_button(approval,true,false,910060);
    poll_shell_button(approval,true,false,910091);        // Immediate, no 300 ms wait.
    assert(s_approval_action.id=="dbl-approval" && s_approval_action.approve);
    assert(session_picker_opens==approval_opens && speech_requests==modal_speech);
    s_approval.disconnect();s_approval_action={};
    // Single A / single B still navigate pages after the gap.
    top_level_shell();s_ui.app=App::Watch;
    raw_single(true);assert(s_page_click==BOARD_BUTTON_A);
    board_next_page();assert(s_ui.app==App::Agent);
    s_page_click=BOARD_BUTTON_NONE;
    raw_single(false);assert(s_page_click==BOARD_BUTTON_B);
    board_previous_page();assert(s_ui.app==App::Watch);
    s_page_click=BOARD_BUTTON_NONE;
    // A then B never produces a Double.
    top_level_shell();s_ui.app=App::Watch;
    const auto cross_opens=session_picker_opens;
    ButtonGesture cross_a,cross_b;uint64_t t=950000;
    poll_shell_button(cross_a,true,true,t);poll_shell_button(cross_a,true,true,t+30);
    poll_shell_button(cross_a,true,false,t+60);poll_shell_button(cross_a,true,false,t+91);
    poll_shell_button(cross_b,false,true,t+100);poll_shell_button(cross_b,false,true,t+130);
    poll_shell_button(cross_b,false,false,t+160);poll_shell_button(cross_b,false,false,t+191);
    assert(session_picker_opens==cross_opens && s_input_modal==InputModal::NONE);
    poll_shell_button(cross_a,true,false,t+91+ButtonGesture::DOUBLE_GAP_MS+2);
    poll_shell_button(cross_b,false,false,t+191+ButtonGesture::DOUBLE_GAP_MS+2);
    s_page_click=BOARD_BUTTON_NONE;
    std::puts("Double A/B top-level only / openAgentSessionPicker reuse / Speech Mode APPLY PASS (host)");
}
// Poll the extracted production GPIO -> policy -> ButtonGesture -> reducer.
// Every press is raw active-low GPIO translated to down=true, as on the board.
void raw_hold(bool a) {
    static uint64_t now=10000;
    ButtonGesture button;
    const auto epoch=s_ui_epoch;const auto modal=s_input_modal;const auto phase=s_settings_phase;
    const auto starts=record_start;
    poll_shell_button(button,a,true,now);
    poll_shell_button(button,a,true,now+29);assert(!button.down());
    poll_shell_button(button,a,true,now+30);assert(button.down());
    poll_shell_button(button,a,true,now+629);
    assert(s_ui_epoch==epoch && s_input_modal==modal && s_settings_phase==phase && record_start==starts);
    poll_shell_button(button,a,true,now+631);
    const auto after_epoch=s_ui_epoch;const auto after_modal=s_input_modal;const auto after_starts=record_start;
    poll_shell_button(button,a,false,now+700);
    poll_shell_button(button,a,false,now+730);assert(!button.down());
    poll_shell_button(button,a,false,now+1000);
    assert(s_ui_epoch==after_epoch && s_input_modal==after_modal && record_start==after_starts);
    assert(s_page_click==BOARD_BUTTON_NONE);
    now+=2000;
}
void raw_settings_regression() {
    s_input_modal=InputModal::NONE;s_page_click=BOARD_BUTTON_NONE;s_ui.close_settings();
    s_ui.app=App::Monitor;
    const auto starts=record_start;const auto writes=saves;
    for(auto menu:{Menu::Root,Menu::View,Menu::Agent,Menu::DeviceDisplay,Menu::Device,Menu::Display,Menu::System}) {
        raw_hold(false);assert(s_input_modal==InputModal::SETTINGS);
        s_ui.menu=menu;raw_hold(true);
        assert(s_ui.app==App::Monitor && record_start==starts && saves==writes);
        if(menu==Menu::Root) assert(s_input_modal==InputModal::NONE && !s_ui.settings_open);
        else {assert(s_ui.menu==((menu==Menu::Device || menu==Menu::Display || menu==Menu::System)?Menu::DeviceDisplay:Menu::Root));raw_hold(false);assert(s_input_modal==InputModal::NONE);}
    }
    raw_hold(false);s_ui.menu=Menu::Device;s_ui.index=0;gesture(false,Gesture::CLICK);
    const auto brightness=s_ui.preferences.brightness;
    gesture(true,Gesture::CLICK);assert(s_ui.draft.brightness!=brightness);
    raw_hold(true);
    assert(s_ui.menu==Menu::Device && s_ui.draft.brightness==brightness);
    assert(s_display.brightness==panel_value(brightness) && saves==writes);
    for(auto field:{Field::Vibration,Field::HomeScreen,Field::Text,Field::Theme}) {
        s_ui.menu=Menu::Edit;s_ui.editor_parent=Menu::Display;s_ui.field=field;
        gesture(true,Gesture::CLICK);raw_hold(true);
        assert(s_ui.menu==Menu::Display && valid(s_ui.draft) && saves==writes);
    }
    s_settings_phase="EFFORT";raw_hold(true);assert(s_settings_phase=="MODEL");
    raw_hold(true);assert(s_settings_phase=="MENU" && s_ui.menu==Menu::Agent);
    for(const char* phase:{"MODEL UPDATED","UPDATE FAILED"}) {
        s_settings_phase=phase;s_settings_id="result";raw_hold(true);
        assert(s_settings_phase=="MENU" && s_ui.menu==Menu::Agent && s_settings_id.empty());
    }
    s_settings_phase="SPEECH";s_speech_menu.load("OFF");s_speech_menu.open_speech();s_speech_menu.click(false);
    gesture(true,Gesture::CLICK);raw_hold(true);
    assert(s_settings_phase=="SPEECH" && s_speech_menu.page==SpeechMenu::Page::SPEECH && s_speech_menu.mode==0);
    raw_hold(true);assert(s_settings_phase=="MENU" && s_ui.menu==Menu::Agent);
    for(const char* phase:{"LOADING","APPLYING","SAVING SPEECH"}) {
        s_settings_phase=phase;s_settings_id="pending";const auto requests=remote_requests;
        raw_hold(true);assert(s_settings_phase==phase && s_settings_id=="pending" && remote_requests==requests);
        raw_hold(false);assert(s_input_modal==InputModal::NONE);raw_hold(false);
    }
    raw_hold(false);assert(s_input_modal==InputModal::NONE);
    assert(record_start==starts && saves==writes); // Every Settings Hold A: Voice starts=0.
    raw_hold(true);assert(record_start==starts+1 && s_input_modal==InputModal::VOICE);
    assert(s_voice_phase=="TRANSCRIBING"); // LONG_RELEASE reached the Voice owner once.
    s_voice_phase="AWAITING_CONFIRMATION";
    const auto sends=voice_send;ButtonGesture voice;
    poll_shell_button(voice,true,true,80000);poll_shell_button(voice,true,true,80030);
    poll_shell_button(voice,true,true,80700);
    assert(record_start==starts+1 && voice_send==sends && s_input_modal==InputModal::VOICE);
    poll_shell_button(voice,true,false,80800);poll_shell_button(voice,true,false,80830);
    // Voice owns the release, preserving the existing explicit SEND behavior.
    assert(record_start==starts+1 && voice_send==sends+1 && s_voice_phase=="SENDING");
    cancel_input(false,"host");
    // A hold begun before Approval arrives cannot answer that new request.
    ButtonGesture a;poll_shell_button(a,true,true,90000);poll_shell_button(a,true,true,90030);
    s_approval_action={};s_approval.receive({"raw-approval","pi","command","details",0},90100);
    poll_shell_button(a,true,true,90700);poll_shell_button(a,true,false,90800);poll_shell_button(a,true,false,90830);
    assert(s_approval_action.id.empty() && record_start==starts+1 && s_approval.current());
    // Inside Approval, no LONG action or navigation: only its authorized release.
    ButtonGesture approval;const auto app=s_ui.app;
    poll_shell_button(approval,true,true,91000);poll_shell_button(approval,true,true,91030);poll_shell_button(approval,true,true,91700);
    assert(s_approval_action.id.empty() && s_input_modal==InputModal::NONE && s_ui.app==app && record_start==starts+1);
    poll_shell_button(approval,true,false,91800);poll_shell_button(approval,true,false,91830);
    assert(s_approval_action.id=="raw-approval" && s_approval_action.approve);
    s_approval.disconnect();
    std::puts("Production raw button polling / all Settings Back paths / Settings Voice starts=0 / Approval ownership PASS (host)");
}
// Touch produces the same virtual A/B gestures as the buttons, through the same
// reducer, so modal and approval ownership are identical.
wearable::TouchSide touch_side;
struct TouchRunner {
    // Use the production reducer so touch_gesture() reads the same side state.
    wearable::TouchGestureReducer& reducer=s_touch_reducer;
    uint64_t now=1000000;
    // Mirror production: capture the ownership epoch on the confirmed Down and
    // clear the contact when the finger lifts.
    void press_step(wearable::TouchGesture g) {
        if(g==wearable::TouchGesture::Down) touch_note_contact_start();
        if(g!=wearable::TouchGesture::None) touch_gesture(g,now);
    }
    void down_at(uint16_t x,uint16_t y) {
        reducer.reset();
        reducer.update(true,x,y,now+=0);
        reducer.update(true,x,y,now+=25);
        press_step(reducer.update(true,x,y,now+=25));
    }
    void release() {
        const auto g=reducer.update(false,0,0,now+=25);
        if(g!=wearable::TouchGesture::None) touch_gesture(g,now);
        reducer.reset();s_touch_contact_valid=false;
    }
    void hold(wearable::TouchSide side,bool long_press=false) {
        const uint16_t x=side==wearable::TouchSide::Right?400:40;
        down_at(x,233);
        if(long_press) {
            const auto start=now;
            while(now-start<650) press_step(reducer.update(true,x,233,now+=25));
        }
        release();
    }
    // Hold while owned until LONG fires (does not release).
    void hold_until_long(uint16_t x) {
        const auto start=now;
        while(now-start<700) press_step(reducer.update(true,x,233,now+=25));
    }
    void swipe(uint16_t x0,uint16_t y0,int dx,int dy) {
        down_at(x0,y0);
        for(int i=1;i<=4;++i) press_step(reducer.update(true,uint16_t(int(x0)+dx*i/4),uint16_t(int(y0)+dy*i/4),now+=25));
        release();
    }
};
void touch_shortcut_regression() {
    top_level_shell();s_cloud_connected=true;
    s_nav.catalog=true;s_nav.target_select=true;
    // Tap right half is virtual A (next page), left half virtual B (previous).
    s_ui.app=App::Watch;s_page_click=BOARD_BUTTON_NONE;
    TouchRunner t;t.hold(wearable::TouchSide::Right);
    assert(s_page_click==BOARD_BUTTON_A);board_next_page();assert(s_ui.app==App::Agent);
    t.hold(wearable::TouchSide::Left);assert(s_page_click==BOARD_BUTTON_B);
    board_previous_page();assert(s_ui.app==App::Watch);
    // Swipe left/right at the top level equals B/A page navigation.
    top_level_shell();s_ui.app=App::Watch;s_page_click=BOARD_BUTTON_NONE;
    t.swipe(400,233,-120,0);assert(s_ui.app==App::Agent);
    t.swipe(100,233,120,0);assert(s_ui.app==App::Watch);
    s_page_click=BOARD_BUTTON_NONE;
    // Approval owns touch: a virtual tap resolves the approval, never a page.
    top_level_shell();s_approval.receive({"touch-approval","pi","command","details",0},1000000);
    s_approval_action={};const auto app=s_ui.app;
    t.hold(wearable::TouchSide::Right);
    assert(s_approval_action.id=="touch-approval" && s_approval_action.approve);
    assert(s_ui.app==app && s_page_click==BOARD_BUTTON_NONE);
    s_approval.disconnect();s_approval_action={};
    // Voice owns touch: right half sends, left half cancels.
    top_level_shell();s_input_modal=InputModal::VOICE;s_voice_phase="AWAITING_CONFIRMATION";
    const auto sends=voice_send;
    t.hold(wearable::TouchSide::Right);
    assert(voice_send==sends+1 && s_voice_phase=="SENDING");
    cancel_input(false,"host");
    // Touch Off ignores touch but the buttons still work.
    top_level_shell();s_ui.preferences.touch_enabled=0;
    s_ui.app=App::Watch;s_page_click=BOARD_BUTTON_NONE;
    /* poll_shell_touch is the gate; the reducer result is discarded when off. */
    assert(!s_ui.preferences.touch_enabled);
    raw_single(true);assert(s_page_click==BOARD_BUTTON_A);s_page_click=BOARD_BUTTON_NONE;
    s_ui.preferences.touch_enabled=1;
    // Agent vertical browse: SwipeDown goes older, then into History.
    top_level_shell();s_ui.app=App::Agent;s_pi.selected=true;s_nav.history=true;s_cloud_connected=true;
    s_agent_browse.reset();s_agent_browse.set_pages(3);
    assert(!s_agent_browse.manual);
    t.swipe(233,100,0,140);
    assert(s_agent_browse.manual && s_agent_browse.page==1);
    t.swipe(233,100,0,-140);
    assert(!s_agent_browse.manual && s_agent_browse.page==0);
    // Past the oldest local page, SwipeDown enters the History browser.
    s_agent_browse.set_pages(1);
    const auto requests=history_requests;
    t.swipe(233,100,0,140);
    assert(s_history_view==wearable::HistoryView::Browser && history_requests==requests+1);
    assert(s_input_modal==InputModal::SETTINGS && s_settings_phase=="HISTORY");
    // A History page populates the bounded cache and screen.
    auto* page=cJSON_CreateObject();
    cJSON_AddStringToObject(page,"type","history_page");
    cJSON_AddStringToObject(page,"request_id",s_history_request.c_str());
    cJSON_AddStringToObject(page,"target",selected_target().c_str());
    cJSON_AddNumberToObject(page,"generation",(double)s_nav.generation);
    cJSON_AddStringToObject(page,"next_cursor","c1");
    auto* list=cJSON_AddArrayToObject(page,"messages");
    auto add=[&](const char* role,const char* text){auto* o=cJSON_CreateObject();cJSON_AddStringToObject(o,"role",role);cJSON_AddStringToObject(o,"text",text);cJSON_AddItemToArray(list,o);};
    add("user","continue the network fix");add("assistant","done, tests pass");
    assert(history_message(page,"history_page"));
    cJSON_Delete(page);
    assert(s_history_cache.size()==1 && s_history_screen.count()==2);
    // A stale response for another target is ignored.
    auto* stale=cJSON_CreateObject();
    cJSON_AddStringToObject(stale,"type","history_page");
    cJSON_AddStringToObject(stale,"request_id",s_history_request.c_str());
    cJSON_AddStringToObject(stale,"target","agent:pi:other");
    cJSON_AddNumberToObject(stale,"generation",(double)s_nav.generation);
    cJSON_AddArrayToObject(stale,"messages");
    const auto pages=s_history_cache.size();
    assert(history_message(stale,"history_page"));
    cJSON_Delete(stale);
    assert(s_history_cache.size()==pages);
    // Buttons page History: A newer, B older; Hold B closes. Both messages here
    // fit one screen, so one press moves one message.
    const auto before=s_history_screen.index;
    gesture(false,Gesture::CLICK);
    assert(s_history_screen.index==before+1 && s_history_screen.page==0);
    gesture(true,Gesture::CLICK);
    assert(s_history_screen.index==before);
    gesture(false,Gesture::LONG);
    assert(s_input_modal==InputModal::NONE && s_history_view==wearable::HistoryView::Closed);
    top_level_shell();
    // View offers History only when the capability is available; Agent has controls.
    s_ui.agent_capabilities(true,true,true,true);
    assert(s_ui.agent_count==7 && s_ui.history_available);
    s_ui.open_settings();s_ui.settings_click(false);assert(s_ui.menu==Menu::View);
    s_ui.index=1;assert(s_ui.settings_click(false)==UiEffect::History);
    s_ui.agent_capabilities(true,true,true,false);
    assert(s_ui.agent_count==7);
    for(unsigned i=0;i<s_ui.agent_count;++i) assert(s_ui.agent_actions[i]!=UiEffect::History);
    // Settings -> Device exposes the Touch On/Off editor.
    s_ui.agent_capabilities(true,true,true,true);
    s_ui.open_settings();s_ui.menu=Menu::Device;
    assert(s_ui.items()==4);
    s_ui.index=2;s_ui.settings_click(false);
    assert(s_ui.menu==Menu::Edit && s_ui.field==Field::Touch);
    const auto was=s_ui.draft.touch_enabled;
    s_ui.settings_click(true);
    assert(s_ui.draft.touch_enabled!=was);
    s_ui.close_settings();
    std::puts("Touch virtual A/B / top-level swipe / Agent browse / History controls / Settings entries PASS (host)");
}
// A touch contact owns the epoch captured at its first valid contact. Ownership
// changes mid-contact must cancel every unresolved gesture.
void touch_ownership_regression() {
    top_level_shell();s_cloud_connected=true;
    s_nav.catalog=true;s_nav.target_select=true;s_ui.app=App::Watch;
    TouchRunner t;
    // Touch Down on the Shell, then Approval appears, then Touch Up: neither
    // approve nor reject, and no page navigation.
    {
        s_page_click=BOARD_BUTTON_NONE;
        t.down_at(400,233);
        assert(s_touch_contact_valid && s_touch_contact_epoch==s_ui_epoch+s_approval.generation);
        const auto app=s_ui.app;
        s_approval_action={};
        s_approval.receive({"takeover-approval","pi","command","details",0},2000000);
        t.release();
        assert(s_approval_action.id.empty());          // MUST NOT approve/reject
        assert(s_ui.app==app);                          // MUST NOT navigate
        assert(s_page_click==BOARD_BUTTON_NONE);
        s_approval.disconnect();
    }
    // Touch Down on the Shell, then a Native UserInput request appears, then
    // Touch Up: the stale contact must not answer it or navigate.
    {
        top_level_shell();s_ui.app=App::Watch;s_page_click=BOARD_BUTTON_NONE;
        t.down_at(400,233);
        const auto app=s_ui.app;
        // Native UserInput takes ownership exactly like the production handler:
        // cancel_input, SETTINGS modal + NAV phase, and a ui_epoch bump.
        s_settings_phase="NAV";s_input_modal=InputModal::SETTINGS;++s_ui_epoch;
        const auto queue=history_requests;
        t.release();
        assert(history_requests==queue);                // no answer/request emitted
        assert(s_input_modal==InputModal::SETTINGS && s_ui.app==app);
        assert(s_page_click==BOARD_BUTTON_NONE);
        cancel_input(false,"host");top_level_shell();
    }
    // A stale contact that would have been a Tap must not navigate after a
    // Settings/Voice style takeover either.
    {
        top_level_shell();s_ui.app=App::Watch;s_page_click=BOARD_BUTTON_NONE;
        t.down_at(400,233);
        ++s_ui_epoch;                                    // ownership changed
        t.release();
        assert(s_page_click==BOARD_BUTTON_NONE && s_ui.app==App::Watch);
    }
    // A stale contact long must not open Voice after an ownership change.
    {
        top_level_shell();const auto starts=record_start;
        const uint16_t x=400;
        t.down_at(x,233);
        ++s_ui_epoch;                                    // takeover mid-hold
        t.hold_until_long(x);                            // LONG fires with stale epoch
        assert(record_start==starts && s_input_modal==InputModal::NONE);
        t.release();
        assert(record_start==starts);
    }
    // A legitimately triggered Touch Long A still opens Voice, and its
    // LongRelease still ends that recording, exactly like a physical hold.
    {
        top_level_shell();s_ui.preferences.touch_enabled=1;
        const auto starts=record_start;
        const uint16_t x=400;
        t.down_at(x,233);
        t.hold_until_long(x);                            // LONG fires while owned
        assert(record_start==starts+1 && s_input_modal==InputModal::VOICE);
        assert(s_voice_phase=="RECORDING");
        // The LONG bumped the epoch; LONG_RELEASE must still reach Voice.
        t.release();
        assert(s_voice_phase=="TRANSCRIBING" && s_input_modal==InputModal::VOICE);
        cancel_input(false,"host");
    }
    top_level_shell();
    std::puts("Touch contact epoch ownership / stale cancellation / Long release PASS (host)");
}
// A long history message that needs several screens is browsed linearly before
// the next older message, reversibly, from buttons and touch alike.
void history_multipage_regression() {
    top_level_shell();s_cloud_connected=true;s_nav.history=true;
    s_pi.selected=true;s_ui.app=App::Agent;
    open_agent_history(true);
    assert(s_input_modal==InputModal::SETTINGS && s_settings_phase=="HISTORY");
    // 120 chars -> 3 screens at the harness 40-char rows; the second message
    // needs one screen. The whole page still respects the 4-message bound.
    std::string long_message(120,'x');
    auto* page=cJSON_CreateObject();
    cJSON_AddStringToObject(page,"type","history_page");
    cJSON_AddStringToObject(page,"request_id",s_history_request.c_str());
    cJSON_AddStringToObject(page,"target",selected_target().c_str());
    cJSON_AddNumberToObject(page,"generation",(double)s_nav.generation);
    auto* list=cJSON_AddArrayToObject(page,"messages");
    auto add=[&](const char* role,const char* text){auto* o=cJSON_CreateObject();cJSON_AddStringToObject(o,"role",role);cJSON_AddStringToObject(o,"text",text);cJSON_AddItemToArray(list,o);};
    add("assistant",long_message.c_str());
    add("user","short question");
    assert(history_message(page,"history_page"));
    cJSON_Delete(page);
    assert(s_history_cache.size()==1 && s_history_screen.count()==2);
    // The long message occupies three screens; each B press advances one screen
    // before the older message appears.
    assert(s_history_screen.index==0 && s_history_screen.page==0);
    assert(s_history_screen.page_count()==3);
    assert(s_history_screen.position()=="1 / 2 \xc2\xb7 1 / 3");
    gesture(false,Gesture::CLICK);
    assert(s_history_screen.index==0 && s_history_screen.page==1);
    assert(s_history_screen.position()=="1 / 2 \xc2\xb7 2 / 3");
    gesture(false,Gesture::CLICK);
    assert(s_history_screen.index==0 && s_history_screen.page==2);
    gesture(false,Gesture::CLICK);
    assert(s_history_screen.index==1 && s_history_screen.page==0);   // older message
    // A returns exactly screen for screen.
    gesture(true,Gesture::CLICK);
    assert(s_history_screen.index==0 && s_history_screen.page==2);
    gesture(true,Gesture::CLICK);
    assert(s_history_screen.index==0 && s_history_screen.page==1);
    gesture(true,Gesture::CLICK);
    assert(s_history_screen.index==0 && s_history_screen.page==0);
    assert(s_history_screen.at_newest());
    // Touch SwipeDown/SwipeUp drive the exact same linear paging.
    TouchRunner t;
    t.swipe(233,100,0,140);                                          // SwipeDown = Older
    assert(s_history_screen.index==0 && s_history_screen.page==1);
    t.swipe(233,300,0,140);
    assert(s_history_screen.index==0 && s_history_screen.page==2);
    t.swipe(233,300,0,140);
    assert(s_history_screen.index==1 && s_history_screen.page==0);
    t.swipe(233,300,0,-140);                                         // SwipeUp = Newer
    assert(s_history_screen.index==0 && s_history_screen.page==2);
    // A new history page resets the text page and the message index.
    s_history_cache.clear();
    s_history_screen.load({},{},{},{});
    auto* fresh=cJSON_CreateObject();
    cJSON_AddStringToObject(fresh,"type","history_page");
    cJSON_AddStringToObject(fresh,"request_id",s_history_request.c_str());
    cJSON_AddStringToObject(fresh,"target",selected_target().c_str());
    cJSON_AddNumberToObject(fresh,"generation",(double)s_nav.generation);
    auto* fresh_list=cJSON_AddArrayToObject(fresh,"messages");
    cJSON* o=cJSON_CreateObject();cJSON_AddStringToObject(o,"role","assistant");cJSON_AddStringToObject(o,"text",long_message.c_str());cJSON_AddItemToArray(fresh_list,o);
    assert(history_message(fresh,"history_page"));
    cJSON_Delete(fresh);
    assert(s_history_screen.index==0 && s_history_screen.page==0);
    gesture(false,Gesture::LONG);                                     // Hold B closes
    assert(s_input_modal==InputModal::NONE && s_history_view==wearable::HistoryView::Closed);
    top_level_shell();
    std::puts("History multi-screen message paging / touch parity / reset PASS (host)");
}
// A NEWER is browsing only: at newest/loading/empty it must keep History,
// regardless of the app from which Settings was entered (especially Watch).
void history_newest_boundary_regression() {
    for(const auto app:{App::Watch,App::Agent,App::Monitor,App::Help}) {
        top_level_shell();s_cloud_connected=true;s_nav.history=true;
        s_pi.selected=true;s_ui.app=app;open_agent_history(true);
        const auto request=s_history_request;
        const auto requests=history_requests;
        gesture(true,Gesture::CLICK);  // Loading has no newer page to show yet.
        assert(s_history_loading && s_history_request==request);
        assert(s_history_view==HistoryView::Browser && s_input_modal==InputModal::SETTINGS);
        assert(s_ui.app==app && s_page_click==BOARD_BUTTON_NONE);
        auto* response=cJSON_CreateObject();
        cJSON_AddStringToObject(response,"request_id",request.c_str());
        cJSON_AddStringToObject(response,"target",selected_target().c_str());
        cJSON_AddNumberToObject(response,"generation",(double)s_nav.generation);
        auto* list=cJSON_AddArrayToObject(response,"messages");
        auto* message=cJSON_CreateObject();
        cJSON_AddStringToObject(message,"role","assistant");
        const std::string text(80,'x');
        cJSON_AddStringToObject(message,"text",text.c_str());
        cJSON_AddItemToArray(list,message);
        assert(history_message(response,"history_page"));cJSON_Delete(response);
        for(unsigned press=0;press<3;++press) gesture(true,Gesture::CLICK);
        assert(s_history_view==HistoryView::Browser && s_input_modal==InputModal::SETTINGS);
        assert(s_ui.app==app && s_page_click==BOARD_BUTTON_NONE);
        assert(s_history_screen.index==0 && s_history_screen.page==0);
        assert(history_requests==requests && s_history_screen.body()==text);
        // B/A remain inverse text-page operations away from the boundary.
        gesture(false,Gesture::CLICK);assert(s_history_screen.page==1);
        gesture(true,Gesture::CLICK);assert(s_history_screen.page==0);
        TouchRunner touch;
        touch.swipe(233,300,0,-140); // SwipeUp uses the same Newer boundary.
        assert(s_history_view==HistoryView::Browser && s_ui.app==app);
        // Empty/error History also cannot leak a short A to top-level Shell.
        s_history_screen.load({},{},{},{});s_history_error="No history";
        gesture(true,Gesture::CLICK);
        assert(s_history_view==HistoryView::Browser && s_ui.app==app);
        gesture(false,Gesture::LONG);
        assert(s_history_view==HistoryView::Closed && s_input_modal==InputModal::NONE);
        assert(s_ui.app==app); // Explicit exit preserves the caller's app.
    }
    top_level_shell();
    std::puts("History A/Newer boundary, loading, empty, touch and explicit exit PASS (host)");
}
// B2: a page fetched from the server becomes the displayed page, and once cached
// Older/Newer traverse the local cache without asking the server again.
void history_cross_page_regression() {
    top_level_shell();s_cloud_connected=true;s_nav.history=true;
    s_pi.selected=true;s_ui.app=App::Agent;
    open_agent_history(true);
    auto deliver=[&](const char* request_id,const char* text,const char* next) {
        auto* page=cJSON_CreateObject();
        cJSON_AddStringToObject(page,"type","history_page");
        cJSON_AddStringToObject(page,"request_id",request_id);
        cJSON_AddStringToObject(page,"target",selected_target().c_str());
        cJSON_AddNumberToObject(page,"generation",(double)s_nav.generation);
        if(next) cJSON_AddStringToObject(page,"next_cursor",next);
        auto* list=cJSON_AddArrayToObject(page,"messages");
        auto* o=cJSON_CreateObject();
        cJSON_AddStringToObject(o,"role","assistant");cJSON_AddStringToObject(o,"text",text);
        cJSON_AddItemToArray(list,o);
        const bool handled=history_message(page,"history_page");
        cJSON_Delete(page);
        return handled;
    };
    // Live page (page 1) arrives from the initial open.
    assert(deliver(s_history_request.c_str(),"page-one","c1"));
    assert(s_history_cache.size()==1 && s_history_cache.index()==0);
    assert(s_history_screen.body()=="page-one");
    // Older past the local end must go to the server, and the returned page 2
    // must become the displayed page (not stay on page 1).
    const auto requests=history_requests;
    gesture(false,Gesture::CLICK);
    assert(history_requests==requests+1);              // one server fetch
    assert(deliver(s_history_request.c_str(),"page-two","c2"));
    assert(s_history_cache.size()==2 && s_history_cache.index()==1);
    assert(s_history_screen.body()=="page-two");       // page 2 is shown
    // Newer returns to page 1 from the cache.
    gesture(true,Gesture::CLICK);
    assert(s_history_cache.index()==0);
    assert(s_history_screen.body()=="page-one");
    // Older again uses the cached page 2: no second server request.
    const auto cached_requests=history_requests;
    gesture(false,Gesture::CLICK);
    assert(history_requests==cached_requests);          // cache hit, no refetch
    assert(s_history_cache.index()==1);
    assert(s_history_screen.body()=="page-two");
    // A third older page extends the window and stays in range.
    gesture(false,Gesture::CLICK);
    assert(history_requests==cached_requests+1);
    assert(deliver(s_history_request.c_str(),"page-three","c3"));
    assert(s_history_cache.index()==2 && s_history_screen.body()=="page-three");
    // A fourth older page evicts the newest end but the index stays valid and
    // still shows the page just requested.
    gesture(false,Gesture::CLICK);
    assert(deliver(s_history_request.c_str(),"page-four","c4"));
    assert(s_history_cache.size()==wearable::AgentHistoryCache::kCachePages);
    assert(s_history_cache.index()==s_history_cache.size()-1);
    assert(s_history_screen.body()=="page-four");
    // Newer walks back through the retained window without refetching.
    const auto window_requests=history_requests;
    gesture(true,Gesture::CLICK);
    assert(history_requests==window_requests);
    gesture(true,Gesture::CLICK);
    assert(history_requests==window_requests);
    // A Newer at the retained window boundary stays in History; only an
    // explicit long-button exit may return to the caller's top-level app.
    assert(s_history_cache.at_newest());
    gesture(true,Gesture::CLICK);
    assert(history_requests==window_requests);
    assert(s_history_view==wearable::HistoryView::Browser);
    assert(s_input_modal==InputModal::SETTINGS && s_ui.app==App::Agent);
    close_agent_history("test");
    top_level_shell();
    std::puts("History cross-page fetch/cache traversal PASS (host)");
}
// B1: a gesture that began under an old epoch must not be committed by the new
// owner. The Navigator is a real reducer here, so a stale CLICK would otherwise
// answer the freshly arrived Native UserInput.
void navigator_stale_epoch_regression() {
    TouchRunner t;
    // Case 1: Touch Down on the old owner, the owner changes to a Native UserInput
    // page (epoch bump), then Touch Up. The stale release must NOT submit a new
    // Native UserInput response.
    {
        top_level_shell();s_ui.app=App::Agent;s_page_click=BOARD_BUTTON_NONE;
        const auto responses=native_responses;
        t.down_at(400,233);                              // begin on the old UI
        assert(s_touch_contact_epoch==s_ui_epoch+s_approval.generation);
        // Native UserInput takes ownership mid-contact, exactly like the real
        // handler: SETTINGS modal + NAV phase and a ui_epoch bump.
        s_settings_phase="NAV";s_input_modal=InputModal::SETTINGS;++s_ui_epoch;
        assert(navigator_owns_gesture());
        t.release();
        assert(native_responses==responses);             // MUST NOT answer
        assert(s_page_click==BOARD_BUTTON_NONE);
        cancel_input(false,"test");top_level_shell();
    }
    // The same stale protection holds for a physical press that spans the change.
    {
        top_level_shell();
        const auto responses=native_responses;
        const uint64_t captured=s_ui_epoch+s_approval.generation;
        s_settings_phase="NAV";s_input_modal=InputModal::SETTINGS;++s_ui_epoch;
        handle_gesture(true,Gesture::CLICK,captured,1000);
        assert(native_responses==responses);
        cancel_input(false,"test");top_level_shell();
    }
    // A gesture owned by the current epoch still answers normally.
    {
        top_level_shell();
        s_settings_phase="NAV";s_input_modal=InputModal::SETTINGS;
        const auto responses=native_responses;
        gesture(true,Gesture::CLICK);
        assert(native_responses==responses+1);
        cancel_input(false,"test");top_level_shell();
    }
    // Case 2: a legitimate long interaction that changes the owner while it is
    // in progress must still finish. Voice opens on a LONG and the release, which
    // arrives after the epoch advanced, must still end the recording.
    {
        top_level_shell();s_ui.preferences.touch_enabled=1;
        const auto starts=record_start;
        const uint16_t x=400;
        t.down_at(x,233);
        t.hold_until_long(x);                            // LONG opens Voice (epoch bump)
        assert(record_start==starts+1 && s_input_modal==InputModal::VOICE);
        assert(s_voice_phase=="RECORDING");
        t.release();                                     // stale epoch, still required
        assert(s_voice_phase=="TRANSCRIBING" && s_input_modal==InputModal::VOICE);
        cancel_input(false,"test");
    }
    top_level_shell();
    std::puts("Navigator stale-epoch ownership / legal Long finish PASS (host)");
}
void reply_reader_regression() {
    for(auto app:{App::Watch,App::Agent,App::Monitor,App::Help}) {
        top_level_shell();s_ui.app=app;s_cloud_connected=false;s_pi.selected=true;
        const std::string original(105,'x');s_pi.final_text=original.c_str();
        // Offline cached reading enters via View, with no remote request.
        const auto requests=history_requests;
        gesture(false,Gesture::LONG);gesture(false,Gesture::CLICK);
        assert(s_ui.menu==Menu::View);
        gesture(false,Gesture::CLICK);
        assert(s_settings_phase=="REPLY" && s_reply_reader.active && history_requests==requests);
        assert(s_reply_reader.screen.page_count()==3 && s_reply_reader.screen.body()==original);
        gesture(true,Gesture::CLICK);assert(s_reply_reader.screen.page==0 && s_ui.app==app);
        gesture(false,Gesture::CLICK);assert(s_reply_reader.screen.page==1);
        // Elapsed time/repeated snapshots cannot rotate or reload the screen.
        test_now_us+=12000000;reply_snapshot_updated(false);
        assert(s_reply_reader.screen.page==1 && !s_reply_reader.updated);
        const auto epoch=s_ui_epoch;
        s_pi.final_text="a new reply";reply_snapshot_updated(false);
        assert(s_reply_reader.updated && s_reply_reader.screen.page==1 && s_ui_epoch==epoch);
        assert(s_reply_reader.screen.body()==original && s_reply_reader.screen.page_count()==3);
        gesture(false,Gesture::CLICK);gesture(false,Gesture::CLICK);
        assert(s_reply_reader.screen.page==2 && s_ui.app==app); // last boundary stays inside
        s_touch_contact_valid=true;s_touch_contact_epoch=s_ui_epoch+s_approval.generation;
        touch_gesture(TouchGesture::SwipeUp,100);
        assert(s_reply_reader.screen.page==1);
        s_touch_contact_epoch=s_ui_epoch+s_approval.generation;
        touch_gesture(TouchGesture::SwipeDown,200);assert(s_reply_reader.screen.page==2);
        // Hold A returns to View, re-entry actively loads the fresh reply.
        gesture(true,Gesture::LONG);assert(!s_reply_reader.active && s_ui.menu==Menu::View);
        gesture(false,Gesture::CLICK);assert(s_reply_reader.screen.body()=="a new reply" && !s_reply_reader.updated);
        gesture(false,Gesture::LONG);assert(!s_reply_reader.active && s_input_modal==InputModal::NONE);
        assert(s_ui.app==app);
    }
    top_level_shell();s_pi.final_text="old session";open_reply_reader();
    reply_snapshot_updated(true);assert(!s_reply_reader.active && s_reply_reader.screen.empty());
    // Absence of a final reply does not turn tool output into assistant text.
    s_pi.final_text="";open_reply_reader();assert(s_reply_reader.source.empty());
    assert(s_reply_reader.screen.body()=="No reply cached");
    s_reply_reader.mark_updated();assert(s_reply_reader.updated && s_reply_reader.screen.page==0);
    gesture(false,Gesture::LONG);s_pi.final_text="";top_level_shell();
    std::puts("View cached reply / fixed body and page / updates badge / touch / boundary / target reset PASS");
}
int main() {
    s_ui.boot({});
    // Offline Long B opens local settings, with no remote request or flash write.
    gesture(false,Gesture::LONG);assert(s_input_modal==InputModal::SETTINGS && s_ui.settings_open && !remote_requests && !saves);
    gesture(false,Gesture::LONG_RELEASE);assert(s_ui.menu==Menu::Root && s_ui.index==0);
    gesture(true,Gesture::CLICK);gesture(true,Gesture::CLICK);gesture(false,Gesture::CLICK);
    assert(s_ui.menu==Menu::DeviceDisplay);gesture(false,Gesture::CLICK);assert(s_ui.menu==Menu::Device);
    gesture(false,Gesture::CLICK);gesture(true,Gesture::CLICK);assert(!saves && s_ui.preferences.brightness==80);
    gesture(false,Gesture::CLICK);assert(saves==1 && s_display.brightness==230 && s_preference_feedback=="APPLIED / NOT SAVED");
    gesture(false,Gesture::LONG);assert(s_input_modal==InputModal::NONE && s_ui.app==App::Watch);
    board_previous_page();assert(s_ui.app==App::Help);
    board_next_page();assert(s_ui.app==App::Watch);
    board_next_page();assert(s_ui.app==App::Agent && s_pi.selected);
    // Every stable Settings page supports Long A, without invoking Voice.
    for(auto menu:{Menu::Root,Menu::View,Menu::Agent,Menu::DeviceDisplay,Menu::Device,Menu::Display,Menu::System}) {
        gesture(false,Gesture::LONG);s_ui.menu=menu;
        gesture(true,Gesture::LONG);
        assert(record_start==0 && saves==1);
        if(menu==Menu::Root) assert(!s_ui.settings_open && s_input_modal==InputModal::NONE);
        else {assert(s_ui.menu==((menu==Menu::Device || menu==Menu::Display || menu==Menu::System)?Menu::DeviceDisplay:Menu::Root));gesture(false,Gesture::LONG);}
    }
    gesture(false,Gesture::LONG);s_ui.menu=Menu::Device;
    gesture(false,Gesture::CLICK); // Brightness editor starts from applied 90.
    const auto applied=s_ui.preferences.brightness;const auto save_count=saves;
    gesture(true,Gesture::CLICK);assert(s_ui.draft.brightness==100 && s_display.brightness==255);
    gesture(true,Gesture::LONG);
    assert(s_ui.menu==Menu::Device && s_ui.preferences.brightness==applied && s_ui.draft.brightness==applied);
    assert(s_display.brightness==panel_value(applied) && saves==save_count);
    save_success=true;
    gesture(false,Gesture::CLICK);gesture(true,Gesture::CLICK);gesture(false,Gesture::CLICK);
    assert(s_ui.menu==Menu::Device && s_ui.preferences.brightness==100 && saves==save_count+1);
    assert(s_saved_preferences.value.brightness==100 && s_preference_feedback=="APPLIED / SAVED");
    gesture(false,Gesture::CLICK);gesture(true,Gesture::CLICK); // 100 -> draft 10.
    assert(s_display.brightness==panel_value(10));
    gesture(true,Gesture::LONG);
    assert(s_saved_preferences.value.brightness==100 && s_ui.preferences.brightness==100);
    assert(s_display.brightness==255 && saves==save_count+1 && s_ui.menu==Menu::Device);
    s_ui.menu=Menu::Display;s_ui.index=1;gesture(false,Gesture::CLICK);
    gesture(true,Gesture::CLICK);gesture(true,Gesture::LONG);assert(s_ui.menu==Menu::Display && s_ui.draft.text_size==s_ui.preferences.text_size);
    s_models={{"model","high",{"low","high"}}};s_settings_phase="EFFORT";
    gesture(true,Gesture::LONG);assert(s_settings_phase=="MODEL");
    gesture(true,Gesture::LONG);assert(s_settings_phase=="MENU" && s_ui.menu==Menu::Agent && s_settings_id.empty());
    for(const char* phase:{"MODEL UPDATED","UPDATE FAILED"}) {
        s_settings_phase=phase;gesture(true,Gesture::LONG);assert(s_settings_phase=="MENU" && s_ui.menu==Menu::Agent);
    }
    for(const char* phase:{"LOADING","APPLYING","SAVING SPEECH"}) {
        s_settings_phase=phase;s_settings_id="pending";const auto requests=remote_requests;
        gesture(true,Gesture::LONG);assert(s_settings_phase==phase && s_settings_id=="pending" && remote_requests==requests);
    }
    s_settings_phase="SPEECH";s_speech_menu.load("OFF");s_speech_menu.open_speech();s_speech_menu.click(false);
    gesture(true,Gesture::CLICK);assert(s_speech_menu.mode==1);
    gesture(true,Gesture::LONG);assert(s_speech_menu.page==SpeechMenu::Page::SPEECH && s_speech_menu.mode==0);
    gesture(true,Gesture::LONG);assert(s_settings_phase=="MENU" && s_ui.menu==Menu::Agent);
    s_settings_phase="SPEECH";s_speech_menu.open();gesture(true,Gesture::LONG);assert(s_ui.menu==Menu::Agent);
    gesture(false,Gesture::LONG);assert(s_input_modal==InputModal::NONE && record_start==0);
    // Navigation never alters current Agent. Voice uses the existing capture path.
    s_cloud_connected=true;gesture(true,Gesture::LONG);assert(record_start==1 && s_input_modal==InputModal::VOICE);
    const auto app=s_ui.app;board_previous_page();assert(s_ui.app==app);
    handle_gesture(true,Gesture::LONG_RELEASE,s_ui_epoch+s_approval.generation,1002);assert(record_stop==1 && !record_cancelled && s_voice_phase=="TRANSCRIBING");
    s_voice_phase="AWAITING_CONFIRMATION";gesture(true,Gesture::CLICK);assert(voice_send==1 && s_voice_phase=="SENDING" && !s_page_click);
    cancel_input(false,"host");s_input_modal=InputModal::VOICE;s_voice_phase="AWAITING_CONFIRMATION";
    gesture(false,Gesture::CLICK);assert(voice_cancel==1 && s_input_modal==InputModal::NONE);
    // Approval owns A/B regardless of the backing app and suppresses long presses.
    s_approval.receive({"approval-a","pi","command","details",0},1000);
    gesture(true,Gesture::LONG);assert(record_start==1);
    gesture(true,Gesture::CLICK);assert(s_approval_action.id=="approval-a" && s_approval_action.approve);
    s_approval.disconnect();s_approval.receive({"approval-b","pi","command","details",0},1000);
    gesture(false,Gesture::CLICK);assert(s_approval_action.id=="approval-b" && !s_approval_action.approve);
    s_approval.disconnect();
    const auto epoch=s_ui_epoch+s_approval.generation;
    handle_gesture(false,Gesture::CLICK,epoch-1,1000);assert(s_page_click==BOARD_BUTTON_NONE);
    raw_settings_regression();
    double_shortcut_regression();
    touch_shortcut_regression();
    touch_ownership_regression();
    reply_reader_regression();
    history_multipage_regression();
    history_newest_boundary_regression();
    history_cross_page_regression();
    navigator_stale_epoch_regression();
    for(uint64_t duration:{999ULL,1000ULL,1001ULL}) {
        s_input_modal=InputModal::VOICE;s_voice_phase="RECORDING";const auto cancels=voice_cancel;
        handle_gesture(true,Gesture::LONG_RELEASE,s_ui_epoch+s_approval.generation,1+duration);
        assert(record_cancelled==(duration<=1000));
        assert(s_voice_phase==(duration<=1000?"Recording too short":"TRANSCRIBING"));
        assert(voice_cancel==cancels+(duration<=1000));
    }
    s_voice_send_mode=VoiceSendMode::Confirm;s_voice_phase="TRANSCRIBING";
    const auto sends=voice_send;
    assert(accept_voice_transcript("确认输入") && s_voice_phase=="AWAITING_CONFIRMATION" && voice_send==sends);
    s_voice_send_mode=VoiceSendMode::Direct;s_voice_phase="TRANSCRIBING";
    assert(accept_voice_transcript("直接输入") && s_voice_phase=="SENDING" && voice_send==sends+1);
    assert(!accept_voice_transcript("duplicate") && voice_send==sends+1);
    s_voice_phase="TRANSCRIBING";assert(!accept_voice_transcript(" ") && voice_send==sends+1);
    s_voice_phase="Recording too short";assert(!accept_voice_transcript("stale") && voice_send==sends+1);
    std::puts("Production Voice 999/1000 CANCEL, 1001 TRANSCRIBING; Confirm/Direct once-only transcript PASS");
    std::puts("Production shell gestures / offline settings / Apply failure / Voice / Approval modal priority PASS (host)");
}
