import json
import subprocess
from pathlib import Path

from src.frontend_bundle import build_frontend


VOICE_CAPTURE = Path(__file__).parents[1] / "frontend" / "voice-issue-capture.js"
VOICE_STORE = Path(__file__).parents[1] / "frontend" / "voice-transcript-store.js"
FRONTEND = VOICE_CAPTURE.parent


def run_node(script: str):
    completed = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )
    assert completed.returncode == 0, completed.stderr
    return json.loads(completed.stdout)


def test_transcript_maps_first_sentence_to_title_and_remainder_to_note_with_field_limits():
    script = f"""
const {{mapVoiceTranscript}} = require({json.dumps(str(VOICE_CAPTURE))});
const mapped = mapVoiceTranscript('  Lift outage on level four. Elevator B is trapped.  ');
const bounded = mapVoiceTranscript('x'.repeat(300) + '. ' + 'y'.repeat(10100));
process.stdout.write(JSON.stringify({{mapped, titleLength:bounded.title.length, bodyLength:bounded.body.length}}));
"""
    assert run_node(script) == {
        "mapped": {
            "title": "Lift outage on level four.",
            "body": "Elevator B is trapped.",
        },
        "titleLength": 255,
        "bodyLength": 10000,
    }


def test_explicit_tap_starts_recognition_and_final_transcript_waits_for_review():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
  constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
  addEventListener(name, callback) {{ this.listeners[name]=callback; }}
  click() {{ this.listeners.click?.({{currentTarget:this}}); }}
  dispatchEvent() {{}}
}}
let instance;
class Recognition {{
  constructor() {{ instance=this; this.started=0; }}
  start() {{ this.started += 1; }}
  stop() {{}}
  abort() {{}}
}}
const elements = Object.fromEntries(
  ['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
);
elements.review.hidden=true;
const controller = createVoiceIssueCapture({{Recognition,elements}});
const before = {{started:Boolean(instance), rootHidden:elements.root.hidden}};
elements.start.click();
instance.onresult({{results:[Object.assign([{{transcript:'Lift outage. Elevator B is trapped.'}}], {{isFinal:true}})]}});
process.stdout.write(JSON.stringify({{
  supported:controller.supported, before, started:instance.started,
  listening:elements.status.textContent, transcript:elements.transcript.value,
  reviewHidden:elements.review.hidden, title:elements.title.value, body:elements.body.value,
  appendLabel:elements.append.textContent, replaceHidden:elements.replace.hidden
}}));
"""
    assert run_node(script) == {
        "supported": True,
        "before": {"started": False, "rootHidden": False},
        "started": 1,
        "listening": "Transcript ready. Review it before using it.",
        "transcript": "Lift outage. Elevator B is trapped.",
        "reviewHidden": False,
        "title": "",
        "body": "",
        "appendLabel": "Use transcript",
        "replaceHidden": True,
    }


def test_final_transcript_is_checkpointed_for_the_confirmed_account():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
  constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
  addEventListener(name, callback) {{ this.listeners[name]=callback; }}
  click() {{ this.listeners.click?.({{currentTarget:this}}); }}
  dispatchEvent() {{}}
}}
let recognition;
class Recognition {{
  constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}}
}}
const elements=Object.fromEntries(
  ['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
);
elements.review.hidden=true;
const saved=[];
const transcriptStore={{
  async load() {{ return ''; }},
  async save(login, transcript) {{ saved.push({{login, transcript}}); }},
  async clear() {{}},
}};
(async () => {{
  const controller=createVoiceIssueCapture({{
    Recognition,elements,transcriptStore,getLogin:()=> 'timmy'
  }});
  await controller.ready;
  elements.start.click();
  recognition.onresult({{results:[Object.assign([{{transcript:'Lift outage.'}}], {{isFinal:true}})]}});
  await Promise.resolve();
  process.stdout.write(JSON.stringify({{saved, transcript:elements.transcript.value}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    assert run_node(script) == {
        "saved": [{"login": "timmy", "transcript": "Lift outage."}],
        "transcript": "Lift outage.",
    }


def test_pending_transcript_recovers_only_for_the_confirmed_account():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
  constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
  addEventListener(name, callback) {{ this.listeners[name]=callback; }}
  dispatchEvent() {{}}
}}
class Recognition {{ start() {{}} stop() {{}} abort() {{}} }}
const elements=Object.fromEntries(
  ['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
);
elements.review.hidden=true;
const loads=[];
(async () => {{
  const controller=createVoiceIssueCapture({{
    Recognition,elements,getLogin:()=> 'timmy',
    transcriptStore:{{
      async load(login) {{ loads.push(login); return 'Recovered lift outage.'; }},
      async save() {{}}, async clear() {{}},
    }},
  }});
  await controller.ready;
  process.stdout.write(JSON.stringify({{
    loads, transcript:elements.transcript.value, reviewHidden:elements.review.hidden,
    status:elements.status.textContent
  }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    assert run_node(script) == {
        "loads": ["timmy"],
        "transcript": "Recovered lift outage.",
        "reviewHidden": False,
        "status": "Recovered transcript. Review it before using or discarding it.",
    }


def test_cumulative_recognition_events_append_each_new_final_segment_once():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
  constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
  addEventListener(name, callback) {{ this.listeners[name]=callback; }}
  click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
}}
const result=(transcript,isFinal)=>Object.assign([{{transcript}}],{{isFinal}});
let recognition;
class Recognition {{ constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}} }}
const elements=Object.fromEntries(
  ['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
);
const saved=[];
(async () => {{
  const controller=createVoiceIssueCapture({{Recognition,elements,getLogin:()=> 'timmy',transcriptStore:{{
    async load() {{ return ''; }}, async save(login,text) {{ saved.push(text); }}, async clear() {{}},
  }}}});
  await controller.ready; elements.start.click();
  const first=result('Lift outage.',true);
  recognition.onresult({{resultIndex:0,results:[first]}});
  recognition.onresult({{
    resultIndex:1,
    results:[first,result('Elevator B',false),result('Elevator B is trapped.',true)],
  }});
  await Promise.resolve();
  process.stdout.write(JSON.stringify({{transcript:elements.transcript.value,saved}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    assert run_node(script) == {
        "transcript": "Lift outage. Elevator B is trapped.",
        "saved": ["Lift outage.", "Lift outage. Elevator B is trapped."],
    }


def test_apply_and_discard_each_clear_the_consumed_checkpoint():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
  constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}}; }}
  addEventListener(name,callback) {{ this.listeners[name]=callback; }}
  click() {{ this.listeners.click?.(); }} dispatchEvent() {{}}
}}
class Recognition {{ start(){{}} stop(){{}} abort(){{}} }}
async function setup() {{
  const elements=Object.fromEntries(
    ['root','start','stop','review','transcript','append','replace','discard','status','title','body'].map(key=>[key,new Element()])
  );
  const cleared=[];
  const controller=createVoiceIssueCapture({{Recognition,elements,getLogin:()=> 'timmy',transcriptStore:{{
    async load() {{ return 'Recovered lift outage.'; }}, async save() {{}},
    async clear(login) {{ cleared.push(login); }},
  }}}});
  await controller.ready;
  return {{elements,cleared}};
}}
(async () => {{
  const applied=await setup(); applied.elements.append.click(); await Promise.resolve();
  const discarded=await setup(); discarded.elements.discard.click(); await Promise.resolve();
  process.stdout.write(JSON.stringify({{
    applied:{{cleared:applied.cleared,reviewHidden:applied.elements.review.hidden,title:applied.elements.title.value}},
    discarded:{{cleared:discarded.cleared,reviewHidden:discarded.elements.review.hidden,transcript:discarded.elements.transcript.value,status:discarded.elements.status.textContent}}
  }}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    assert run_node(script) == {
        "applied": {
            "cleared": ["timmy"],
            "reviewHidden": True,
            "title": "Recovered lift outage.",
        },
        "discarded": {
            "cleared": ["timmy"],
            "reviewHidden": True,
            "transcript": "",
            "status": "Recovered transcript discarded.",
        },
    }


def test_review_requires_explicit_append_or_replace_when_typed_content_exists():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
  constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; this.events=[]; }}
  addEventListener(name, callback) {{ this.listeners[name]=callback; }}
  click() {{ this.listeners.click?.({{currentTarget:this}}); }}
  dispatchEvent(event) {{ this.events.push(event.type); }}
}}
function setup() {{
  let instance;
  class Recognition {{
    constructor() {{ instance=this; }} start() {{}} stop() {{}} abort() {{}}
  }}
  const elements=Object.fromEntries(
    ['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
  );
  elements.title.value='Existing title'; elements.body.value='Existing note'; elements.review.hidden=true;
  createVoiceIssueCapture({{Recognition,elements, createEvent:name=>({{type:name}})}});
  elements.start.click();
  instance.onresult({{results:[Object.assign([{{transcript:'Lift outage. Elevator B is trapped.'}}], {{isFinal:true}})]}});
  return elements;
}}
const appended=setup(); appended.append.click();
const replaced=setup(); replaced.replace.click();
process.stdout.write(JSON.stringify({{
  appended:{{title:appended.title.value,body:appended.body.value,events:[appended.title.events,appended.body.events]}},
  replaced:{{title:replaced.title.value,body:replaced.body.value,events:[replaced.title.events,replaced.body.events]}}
}}));
"""
    assert run_node(script) == {
        "appended": {
            "title": "Existing title",
            "body": "Existing note\n\nLift outage.\n\nElevator B is trapped.",
            "events": [["input"], ["input"]],
        },
        "replaced": {
            "title": "Lift outage.",
            "body": "Elevator B is trapped.",
            "events": [["input"], ["input"]],
        },
    }


def test_unsupported_browser_keeps_keyboard_flow_and_exposes_no_voice_control():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
const element=()=>({{hidden:false,textContent:'',addEventListener(){{}}}});
const elements=Object.fromEntries(
  ['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,element()])
);
const controller=createVoiceIssueCapture({{Recognition:null,elements}});
process.stdout.write(JSON.stringify({{
  supported:controller.supported, hidden:elements.root.hidden, guidance:elements.status.textContent
}}));
"""
    assert run_node(script) == {
        "supported": False,
        "hidden": True,
        "guidance": "Voice capture is unavailable; type the title and note instead.",
    }


def test_recognition_start_rejection_restores_controls_and_preserves_draft():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
  constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
  addEventListener(name, callback) {{ this.listeners[name]=callback; }}
  click() {{ this.listeners.click?.({{currentTarget:this}}); }} dispatchEvent() {{}}
}}
class Recognition {{
  start() {{ throw new Error('microphone blocked by browser policy'); }}
  stop() {{}} abort() {{}}
}}
const elements=Object.fromEntries(
  ['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
);
elements.title.value='Existing title';
elements.body.value='Existing note';
elements.transcript.value='Recovered transcript.';
elements.start.hidden=false; elements.stop.hidden=true;
createVoiceIssueCapture({{Recognition,elements}});
elements.start.click();
process.stdout.write(JSON.stringify({{
  startHidden:elements.start.hidden, stopHidden:elements.stop.hidden,
  status:elements.status.textContent, title:elements.title.value,
  body:elements.body.value, transcript:elements.transcript.value
}}));
"""
    assert run_node(script) == {
        "startHidden": False,
        "stopHidden": True,
        "status": "Voice capture could not start. Your draft and transcript are unchanged.",
        "title": "Existing title",
        "body": "Existing note",
        "transcript": "Recovered transcript.",
    }


def test_stop_error_and_sheet_close_release_recognition_without_losing_transcript():
    script = f"""
const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
  constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }}
  addEventListener(name, callback) {{ this.listeners[name]=callback; }}
  click() {{ this.listeners.click?.({{currentTarget:this}}); }} dispatchEvent() {{}}
}}
let instance;
class Recognition {{
  constructor() {{ instance=this; this.stops=0; this.aborts=0; }}
  start() {{}} stop() {{ this.stops+=1; }} abort() {{ this.aborts+=1; }}
}}
const elements=Object.fromEntries(
  ['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()])
);
const controller=createVoiceIssueCapture({{Recognition,elements}});
elements.start.click();
instance.onresult({{results:[Object.assign([{{transcript:'Partial final transcript.'}}], {{isFinal:true}})]}});
elements.stop.click();
const afterStop=elements.status.textContent;
instance.onerror({{error:'not-allowed'}});
const afterError=elements.status.textContent;
controller.cancel();
process.stdout.write(JSON.stringify({{
  stops:instance.stops, aborts:instance.aborts, afterStop, afterError,
  transcript:elements.transcript.value, reviewHidden:elements.review.hidden
}}));
"""
    assert run_node(script) == {
        "stops": 1,
        "aborts": 1,
        "afterStop": "Finishing transcript…",
        "afterError": "Microphone permission was denied. Your draft and transcript are unchanged.",
        "transcript": "Partial final transcript.",
        "reviewHidden": False,
    }


def test_private_store_isolates_bounded_transcripts_by_normalized_account():
    script = f"""
const createVoiceTranscriptStore=require({json.dumps(str(VOICE_STORE))});
const records=new Map();
const transaction=async work=>work({{
  get:async key=>records.get(key),
  put:async value=>records.set(value.login,{{...value}}),
  delete:async key=>records.delete(key),
}});
(async()=>{{
  const store=createVoiceTranscriptStore({{transaction}});
  await store.save(' Timmy ', '  Lift   outage.  ' + 'x'.repeat(11000));
  await store.save('alexander', 'Private second account');
  const before={{timmy:await store.load('timmy'),alexander:await store.load('alexander')}};
  await store.clear('TIMMY');
  process.stdout.write(JSON.stringify({{
    before,after:await store.load('timmy'),keys:[...records.keys()],length:before.timmy.length
  }}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    assert run_node(script) == {
        "before": {
            "timmy": "Lift outage. " + "x" * 10242,
            "alexander": "Private second account",
        },
        "after": "",
        "keys": ["alexander"],
        "length": 10255,
    }


def test_mobile_new_sheet_ships_accessible_voice_review_inside_issue_capture_feature():
    build = build_frontend(FRONTEND)
    html = build.dashboard_html
    capture_bundle = build.feature_bundles["issue-capture"].runtime_bytes
    dashboard = (FRONTEND / "dashboard.js").read_text()

    assert 'id="voice-issue-capture"' in html
    assert 'id="start-voice-issue-capture"' in html
    assert 'id="stop-voice-issue-capture"' in html
    assert 'id="voice-issue-transcript"' in html
    assert 'id="voice-issue-status" class="small" aria-live="polite"' in html
    assert 'id="append-voice-issue-transcript"' in html
    assert 'id="replace-with-voice-issue-transcript"' in html
    assert 'id="discard-voice-issue-transcript"' in html
    assert b"function createVoiceIssueCapture" in capture_bundle
    assert b"function createVoiceTranscriptStore" in capture_bundle
    assert b"function createVoiceIssueCapture" not in build.runtime_bytes
    assert "window.SpeechRecognition || window.webkitSpeechRecognition" in dashboard
    assert "transcriptStore:createVoiceTranscriptStore()" in dashboard
    assert "getLogin:()=>confirmedOwnerLogin" in dashboard
    assert "voiceIssueCapture.cancel();" in dashboard
    registry = run_node(
        f"process.stdout.write(JSON.stringify(require({json.dumps(str(FRONTEND / 'private-data-registry.js'))})))"
    )
    assert "stackchain-voice-transcripts-v1" in registry
