import json
import subprocess
from pathlib import Path

import pytest

from tests.dashboard_bundle import dashboard


UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
DRAFT_SESSION = Path(__file__).parents[1] / "frontend" / "draft-filing-session.js"


def run_node(script: str):
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    return json.loads(result.stdout)


def test_draft_filing_session_navigates_stable_capture_ids_and_reconciles_removed_items():
    script = f"""
const createDraftFilingSession = require({json.dumps(str(DRAFT_SESSION))});
let captures = [{{id:'newest'}}, {{id:'middle'}}, {{id:'oldest'}}];
const session = createDraftFilingSession({{list:()=>captures}});
const opened = session.start('middle');
const skipped = session.next();
captures = [{{id:'newest'}}];
const afterFiled = session.removeCurrentAndNext('oldest');
const previous = session.previous();
process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
"""
    output = run_node(script)

    assert output == {
        "opened": {"id": "middle", "position": 2, "total": 3, "remaining": 1},
        "skipped": {"id": "oldest", "position": 3, "total": 3, "remaining": 0},
        "afterFiled": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
        "previous": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
    }


def test_draft_filing_session_restores_all_saved_evidence():
    script = f"""
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
const evidence=[{{filename:'one.png'}},{{filename:'two.png'}}];
const restored=[];
const elements=new Map();
const qs=selector=>{{
  if(!elements.has(selector)) elements.set(selector,{{hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}}});
  return elements.get(selector);
}};
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
session.attach(qs,{{
  captures:{{resume:async()=>({{repository:'o/r',title:'Planned',body:'',labelIds:[],attachments:evidence}})}},
  issueCapture:{{saveDraft:()=>{{}}}},attachment:{{restore:value=>restored.push(value),clear:()=>restored.push(null)}},
  getLogin:()=>'timmy',setResumedId:()=>{{}},openSheet:async()=>{{}},setFilingMode:()=>{{}},
}});
session.start('planned');
(async()=>{{await session.nextCapture();process.stdout.write(JSON.stringify(restored));}})();
"""
    assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]]


def test_ready_draft_summary_exposes_truthful_mobile_action_and_plan_details():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const planned=createUnfiledCaptures.summary({{
  filingPlan:{{repository:'stackchain/dashboard',completionIntent:'create-and-start',estimateMinutes:45}},
  attachmentCount:2,
}});
const minimal=createUnfiledCaptures.summary({{}});
const titles=[
  createUnfiledCaptures.displayTitle({{title:'Pump failure',hasAttachment:true}}),
  createUnfiledCaptures.displayTitle({{title:'',hasAttachment:true}}),
];
process.stdout.write(JSON.stringify({{planned,minimal,titles}}));
"""

    assert run_node(script) == {
        "planned": {
            "label": "Ready to file",
            "repository": "stackchain/dashboard",
            "ready": True,
            "action": "Review & start",
            "details": "Create & start · 45 min · 2 screenshots",
        },
        "minimal": {
            "label": "Needs filing",
            "repository": "",
            "ready": False,
            "action": "Choose repository",
            "details": "Create issue · No screenshots",
        },
        "titles": ["Pump failure", "Untitled photo draft"],
    }


def test_capture_title_focus_defers_phone_keyboard_until_draft_continuation():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const shouldFocus=createUnfiledCaptures.shouldFocusTitle;
process.stdout.write(JSON.stringify({{
  freshPhone:shouldFocus({{mobile:true,continuing:false}}),
  resumedPhoto:shouldFocus({{mobile:true,continuing:true}}),
  freshDesktop:shouldFocus({{mobile:false,continuing:false}}),
}}));
"""
    assert run_node(script) == {
        "freshPhone": False,
        "resumedPhoto": True,
        "freshDesktop": True,
    }


def test_ready_draft_opens_final_review_with_restored_intent_and_evidence():
    script = f"""
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
const events=[];
const trigger={{id:'ready-action'}};
const resumed={{repository:'o/r',title:'Planned',body:'Evidence',labelIds:[7],
  completionIntent:'create-and-start',estimateMinutes:30,
  attachments:[{{filename:'one.png'}},{{filename:'two.png'}}]}};
const elements=new Map();
const qs=selector=>{{
  if(!elements.has(selector)) elements.set(selector,{{id:selector.slice(1),hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}},
    requestSubmit:button=>events.push(['review',resumed,button.id])}});
  return elements.get(selector);
}};
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
session.attach(qs,{{
  captures:{{resume:async()=>resumed}},
  capture:()=>({{saveDraft:value=>events.push(['save',value])}}),
  attachment:{{restore:value=>events.push(['evidence',value]),clear:()=>{{}}}},
  getLogin:()=>'timmy',setResumedId:id=>events.push(['source',id]),
  openSheet:async()=>events.push(['sheet']),setFilingMode:()=>events.push(['filing']),
  review:async()=>{{events.push(['metadata']);events.push(['duplicates']);}},
}});
session.start('planned');
(async()=>{{await session.reviewCapture('planned',trigger);process.stdout.write(JSON.stringify({{
  events,origin:session.takeTrigger().id
}}));}})();
"""

    output = run_node(script)
    events = output["events"]
    assert [event[0] for event in events] == [
        "save", "source", "sheet", "evidence", "filing", "metadata",
        "duplicates", "review"
    ]
    assert events[-1][1]["labelIds"] == [7]
    assert events[-1][1]["attachments"] == [
        {"filename": "one.png"}, {"filename": "two.png"}
    ]
    assert events[-1][2] == "create-and-start-issue"
    assert output["origin"] == "ready-action"


def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
    script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let sequence = 0;
const captures = createUnfiledCaptures({{
  storage,
  getCaptureLogin:()=>'timmy',
  getCurrentLogin:()=>'',
  createId:()=>String(++sequence),
  now:()=>1000 + sequence,
}});
for (let index = 1; index <= 20; index += 1) {{
  captures.save({{title:'Note ' + index, body:'Context ' + index}});
}}
let fullError = '';
try {{ captures.save({{title:'Note 21', body:'Context 21'}}); }} catch (error) {{
  fullError = error.message;
}}
const beforeReplace = captures.list();
const oldest = captures.capacity().oldest;
const replacement = captures.replaceOldest({{title:'Note 21', body:'Context 21'}}, oldest.id);
const offline = captures.list();
const restored = createUnfiledCaptures({{
  storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
}}).list();
process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement,offline,restored}}));
"""
    output = run_node(script)

    assert output["fullError"] == "Drafts full — nothing was deleted."
    assert len(output["beforeReplace"]) == 20
    assert output["beforeReplace"][-1]["title"] == "Note 1"
    assert output["oldest"]["title"] == "Note 1"
    assert len(output["offline"]) == 20
    assert output["offline"][0]["title"] == "Note 21"
    assert output["offline"][-1]["title"] == "Note 2"
    assert all(item["quarantined"] for item in output["offline"])
    assert all(not item["quarantined"] for item in output["restored"])
    assert output["restored"][0]["ownerLogin"] == "timmy"


def test_unfiled_capture_restores_ordered_evidence_bundle():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}}),note:'Note '+name}});
(async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}});
const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
const resumed=await captures.resume(saved.id,'timmy');
process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),notes:resumed.attachments.map(x=>x.note),stored:blobs.get('bundle').attachments.map(x=>x.note)}}));}})();
"""
    output = run_node(script)
    assert output["listed"]["attachmentCount"] == 3
    assert output["names"] == ["one", "two", "three"]
    assert output["notes"] == ["Note one", "Note two", "Note three"]
    assert output["stored"] == output["notes"]


def test_unfiled_capture_saves_untitled_photo_only_draft_but_rejects_empty_capture():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
const photo={{filename:'field.jpg',contentType:'image/jpeg',blob:new Blob(['photo'],{{type:'image/jpeg'}}),note:'Pump label'}};
(async()=>{{
  const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'photo-only'}});
  const saved=await captures.save({{title:'   ',body:'',attachments:[photo]}});
  const listed=captures.list()[0];
  const resumed=await captures.resume(saved.id,'timmy');
  const remoteValues=new Map(),remoteBlobs=new Map();
  const remote=createUnfiledCaptures({{
    storage:{{getItem:k=>remoteValues.get(k)||null,setItem:(k,v)=>remoteValues.set(k,v)}},
    attachmentStore:{{put:async(id,value)=>remoteBlobs.set(id,value),get:async id=>remoteBlobs.get(id),delete:async id=>remoteBlobs.delete(id)}},
    getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'unused'
  }});
  await remote.mergeRemote(await captures.exportOwned('timmy'),'timmy');
  const remoteResumed=await remote.resume(saved.id,'timmy');
  let emptyError='';
  try {{ await captures.save({{title:' ',body:''}}); }} catch(error) {{ emptyError=error.message; }}
  process.stdout.write(JSON.stringify({{saved,listed,resumedTitle:resumed.title,resumedEvidence:resumed.attachments.map(item=>item.filename),remoteTitle:remoteResumed.title,remoteEvidence:remoteResumed.attachments.map(item=>item.filename),emptyError}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)
    assert output["saved"]["title"] == ""
    assert output["listed"]["title"] == ""
    assert output["resumedTitle"] == ""
    assert output["resumedEvidence"] == ["field.jpg"]
    assert output["remoteTitle"] == ""
    assert output["remoteEvidence"] == ["field.jpg"]
    assert output["emptyError"] == "Add a title or photo before saving."


def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
function device() {{
  const values=new Map(), blobs=new Map();
  return {{
    captures:createUnfiledCaptures({{
      storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}},
      attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}},
      getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42,
    }}), blobs,
  }};
}}
(async()=>{{
  const phone=device(), desktop=device();
  const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}});
  await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}});
  const exported=await phone.captures.exportOwned('timmy');
  const imported=await desktop.captures.mergeRemote(exported,'timmy');
  const resumed=await desktop.captures.resume('phone-draft','timmy');
  process.stdout.write(JSON.stringify({{
    exported:exported[0], imported,
    names:resumed.attachments.map(item=>item.filename),
    notes:resumed.attachments.map(item=>item.note),
    bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())),
    blockers:resumed.blockers,
  }}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output["exported"]["id"] == "phone-draft"
    assert output["exported"]["saved_at"] == 42
    assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"]
    assert output["imported"] == 1
    assert output["names"] == ["one.png", "two.png"]
    assert output["notes"] == ["First", "Second"]
    assert output["bytes"] == ["one.png", "two.png"]
    assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]


def test_unfiled_captures_resume_complete_filing_plan_on_another_device():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
function device(id) {{
  const values=new Map();
  return createUnfiledCaptures({{
    storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
    getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>id,now:()=>42,
  }});
}}
(async()=>{{
  const phone=device('planned-draft'), desktop=device('unused');
  await phone.save({{
    repository:'stackchain/dashboard',title:'Restore my plan',body:'Do not make me plan twice',
    labelIds:[7,3,7],milestoneId:12,dueDate:'2026-08-21',
    templateName:'Bug report',templateId:'bug.yml',capturedBody:'Original field notes',
    assignee:'alexander',assigneeName:'Alexander',unassigned:false,
    estimateMinutes:45,completionIntent:'create-and-start',
    blockers:[{{repository:'stackchain/api',number:9,title:'API rollout'}}],
  }});
  const exported=await phone.exportOwned('timmy');
  await desktop.mergeRemote(exported,'timmy');
  const resumed=await desktop.resume('planned-draft','timmy');
  process.stdout.write(JSON.stringify({{exported:exported[0],resumed}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output["exported"]["filing_plan"] == {
        "repository": "stackchain/dashboard",
        "label_ids": [7, 3],
        "milestone_id": 12,
        "due_date": "2026-08-21",
        "template_name": "Bug report",
        "template_id": "bug.yml",
        "captured_body": "Original field notes",
        "assignee": "alexander",
        "assignee_name": "Alexander",
        "estimate_minutes": 45,
        "completion_intent": "create-and-start",
    }
    assert output["resumed"] == {
        "repository": "stackchain/dashboard",
        "title": "Restore my plan",
        "body": "Do not make me plan twice",
        "labelIds": [7, 3],
        "milestoneId": 12,
        "dueDate": "2026-08-21",
        "templateName": "Bug report",
        "templateId": "bug.yml",
        "capturedBody": "Original field notes",
        "assignee": "alexander",
        "assigneeName": "Alexander",
        "estimateMinutes": 45,
        "completionIntent": "create-and-start",
        "blockers": [{"repository": "stackchain/api", "number": 9, "title": "API rollout"}],
    }


def test_unfiled_captures_reconcile_remote_replaces_stale_content_and_evidence():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}};
const captures=createUnfiledCaptures({{
  storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1,
}});
(async()=>{{
  await captures.save({{title:'Field failure',body:'phone edit',attachment:{{
    filename:'phone.png',contentType:'image/png',blob:new Blob(['phone'])
  }}}});
  await captures.reconcileRemote([{{
    id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{
      filename:'desktop.png',content_type:'image/png',note:'newer',data:'ZGVza3RvcA=='
    }}]
  }}], 'timmy');
  const resumed=await captures.resume('field','timmy');
  process.stdout.write(JSON.stringify({{
    body:resumed.body,filename:resumed.attachments[0].filename,note:resumed.attachments[0].note,
    bytes:await resumed.attachments[0].blob.text(),count:captures.list().length,
  }}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output == {
        "body": "desktop edit",
        "filename": "desktop.png",
        "note": "newer",
        "bytes": "desktop",
        "count": 1,
    }


def test_unfiled_captures_reconcile_rolls_back_evidence_when_metadata_write_fails():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();let failWrite=false;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>{{if(failWrite)throw new Error('quota');values.set(k,v)}}}};
const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}};
const captures=createUnfiledCaptures({{
  storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1,
}});
(async()=>{{
  await captures.save({{title:'Field failure',body:'phone edit',attachment:{{
    filename:'phone.png',contentType:'image/png',blob:new Blob(['phone'])
  }}}});
  failWrite=true;let error='';
  try {{
    await captures.reconcileRemote([{{
      id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{
        filename:'desktop.png',content_type:'image/png',data:'ZGVza3RvcA=='
      }}]
    }}], 'timmy');
  }} catch (caught) {{ error=caught.message; }}
  const resumed=await captures.resume('field','timmy');
  const proof=resumed.attachments?.[0] || resumed.attachment;
  process.stdout.write(JSON.stringify({{
    error,body:resumed.body,filename:proof.filename,
    bytes:await proof.blob.text(),
  }}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output == {
        "error": "quota",
        "body": "phone edit",
        "filename": "phone.png",
        "bytes": "phone",
    }


def test_unfiled_capture_restores_selected_blockers():
    script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const captures=createUnfiledCaptures({{storage,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'blocked'}});
const blockers=[{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}];
const saved=captures.save({{title:'Blocked work',body:'Context',blockers}});
const resumed=captures.resume(saved.id,'timmy');
process.stdout.write(JSON.stringify({{listed:captures.list()[0],resumed}}));
"""
    output = run_node(script)
    assert output["listed"]["blockerCount"] == 2
    assert output["resumed"]["blockers"] == [
        {"repository": "o/api", "number": 7, "title": "API ready"},
        {"repository": "o/web", "number": 8, "title": "Web ready"},
    ]


def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
    script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const deleted = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
  put: async (id, attachment) => blobs.set(id, attachment),
  get: async id => blobs.get(id) || null,
  delete: async id => {{ deleted.push(id); blobs.delete(id); }},
}};
(async () => {{
  let id = 0;
  const captures = createUnfiledCaptures({{
    storage, attachmentStore, maxItems:2, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
    createId:()=>String(++id), now:()=>id,
  }});
  const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
  const first = await captures.save({{title:'First',body:'one',attachment:image('first.png')}});
  const second = await captures.save({{title:'Second',body:'two',attachment:image('second.png')}});
  let mismatch = '';
  try {{ await captures.replaceOldest({{title:'Third',body:'three'}}, second.id); }}
  catch (error) {{ mismatch = error.message; }}
  const third = await captures.replaceOldest({{title:'Third',body:'three',attachment:image('third.png')}}, first.id);
  process.stdout.write(JSON.stringify({{
    mismatch, titles:captures.list().map(item=>item.title), deleted,
    blobs:[...blobs.keys()], third:third.title,
  }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    output = run_node(script)

    assert output == {
        "mismatch": "Drafts changed. Review them before replacing anything.",
        "titles": ["Third", "Second"],
        "deleted": ["1"],
        "blobs": ["2", "3"],
        "third": "Third",
    }


def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob():
    script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
  put: async (id, attachment) => blobs.set(id, attachment),
  get: async id => blobs.get(id) || null,
  delete: async id => {{
    if (id === '1') throw new Error('IndexedDB delete failed');
    blobs.delete(id);
  }},
}};
(async () => {{
  let id = 0;
  const captures = createUnfiledCaptures({{
    storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
    createId:()=>String(++id), now:()=>id,
  }});
  const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
  const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}});
  let error = '';
  try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }}
  catch (caught) {{ error = caught.message; }}
  const resumed = await captures.resume(original.id, 'timmy');
  process.stdout.write(JSON.stringify({{
    error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()],
    resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename,
  }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    output = run_node(script)

    assert output == {
        "error": "IndexedDB delete failed",
        "titles": ["Original"],
        "blobs": ["1"],
        "resumedTitle": "Original",
        "resumedFilename": "original.png",
    }


def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable():
    script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
  put: async (id, attachment) => blobs.set(id, attachment),
  get: async id => blobs.get(id) || null,
  delete: async () => {{ throw new Error('IndexedDB delete failed'); }},
}};
(async () => {{
  const captures = createUnfiledCaptures({{
    storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
    createId:()=>'capture-1', now:()=>42,
  }});
  await captures.save({{title:'Keep this',body:'Important',attachment:{{
    filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
  }}}});
  let error = '';
  try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
  const resumed = await captures.resume('capture-1', 'timmy');
  process.stdout.write(JSON.stringify({{
    error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title,
    resumedFilename:resumed.attachment.filename,
  }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    output = run_node(script)

    assert output == {
        "error": "IndexedDB delete failed",
        "titles": ["Keep this"],
        "resumedTitle": "Keep this",
        "resumedFilename": "proof.png",
    }


def test_discard_metadata_write_failure_restores_attachment_and_draft():
    script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
let failWrites = false;
const storage = {{
  getItem:k=>values.get(k)||null,
  setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); values.set(k,v); }},
}};
const attachmentStore = {{
  put: async (id, attachment) => blobs.set(id, attachment),
  get: async id => blobs.get(id) || null,
  delete: async id => blobs.delete(id),
}};
(async () => {{
  const captures = createUnfiledCaptures({{
    storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
    createId:()=>'capture-1', now:()=>42,
  }});
  await captures.save({{title:'Keep this',body:'Important',attachment:{{
    filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
  }}}});
  failWrites = true;
  let error = '';
  try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
  failWrites = false;
  const resumed = await captures.resume('capture-1', 'timmy');
  process.stdout.write(JSON.stringify({{
    error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()],
    resumedFilename:resumed.attachment.filename,
  }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    output = run_node(script)

    assert output == {
        "error": "Storage write failed",
        "titles": ["Keep this"],
        "blobIds": ["capture-1"],
        "resumedFilename": "proof.png",
    }


def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
    script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let id = 0;
const captures = createUnfiledCaptures({{
  storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id
}});
const first = captures.save({{title:'First',body:'One'}});
const second = captures.save({{title:'Second',body:'Two'}});
let mismatch = '';
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
const resumed = captures.resume(first.id, 'timmy');
captures.completeResume(first.id);
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
"""
    output = run_node(script)

    assert output["mismatch"] == "Reconnect with the account that saved this capture."
    assert output["resumed"] == {"repository": "", "title": "First", "body": "One", "labelIds": []}
    assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]


def test_unfiled_capture_durably_restores_screenshot_before_explicit_handoff_completion():
    script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
  put: async (id, attachment) => blobs.set(id, attachment),
  get: async id => blobs.get(id) || null,
  delete: async id => blobs.delete(id),
}};
(async () => {{
  const captures = createUnfiledCaptures({{
    storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
    createId:()=>'capture-1', now:()=>42,
  }});
  const screenshot = {{filename:'phone.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}};
  const saved = await captures.save({{title:'Broken mobile layout',body:'At 320px',attachment:screenshot}});
  const listed = captures.list();
  const resumed = await captures.resume(saved.id, 'timmy');
  const beforeComplete = captures.list().length;
  await captures.completeResume(saved.id);
  process.stdout.write(JSON.stringify({{
    listed, beforeComplete, afterComplete:captures.list().length,
    resumed:{{title:resumed.title,body:resumed.body,filename:resumed.attachment.filename,
      contentType:resumed.attachment.contentType,size:resumed.attachment.blob.size}},
    blobRemoved:!blobs.has(saved.id),
  }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    output = run_node(script)

    assert output["listed"][0]["hasAttachment"] is True
    assert "attachment" not in output["listed"][0]
    assert output["beforeComplete"] == 1
    assert output["afterComplete"] == 0
    assert output["blobRemoved"] is True
    assert output["resumed"] == {
        "title": "Broken mobile layout",
        "body": "At 320px",
        "filename": "phone.png",
        "contentType": "image/png",
        "size": 5,
    }


def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
    script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const captures = createUnfiledCaptures({{storage,getCaptureLogin:()=>''}});
const errors = [];
for (const note of [{{title:'',body:'context'}},{{title:'Work',body:'context'}}]) {{
  try {{ captures.save(note); }} catch (error) {{ errors.push(error.message); }}
}}
process.stdout.write(JSON.stringify({{errors,size:values.size}}));
"""
    output = run_node(script)

    assert output == {
        "errors": ["Add a title or photo before saving.", "Offline identity is unavailable. Reconnect once before saving private work."],
        "size": 0,
    }


@pytest.mark.anyio
async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume_flow():
    html = await dashboard()

    assert '<script src="static/unfiled-captures.js"></script>' in html
    assert 'id="save-unfiled-issue"' in html
    assert 'Save to Drafts' in html
    assert "createUnfiledCaptures({" in html
    assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
    assert "unfiledCaptures.save(captureDraft)" in html
    assert "const savedCapture = await unfiledCaptures.save(captureDraft)" in html
    assert "mobileTaskDock.select('queues')" in html
    assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
    assert "requestAnimationFrame(() =>" in html
    assert "savedCard?.scrollIntoView({block:'nearest'})" in html
    assert "Saved to Drafts. Choose a repository when you’re ready to file it." in UNFILED.read_text()
    assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
    assert "await createIssueAttachmentController.serialize()" in html
    assert "createUnfiledAttachmentStore()" in html
    assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text()
    assert "await unfiledCaptures.completeResume(rUC)" in html
    assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
    assert "title:unfiledDraftDisplayTitle(item)" in html
    assert "item.hasAttachment ? 'Photo evidence attached' : ''" in html
    assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
    assert "item.kind === 'unfiled-issue'" in html
    assert '.create-issue-actions button { min-height:44px;' in html
    assert '.draft-card:focus-visible' in html
    assert 'scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom))' in html
    assert '@media(max-width:320px)' in html


@pytest.mark.anyio
async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning():
    html = await dashboard()
    feature = UNFILED.read_text()

    assert "const captureDraft = currentIssueCaptureDraft();" in html
    assert "return withFilingEstimate(issueTemplatePicker.fields(" in html
    assert "draft.estimateMinutes = Number(value) || null" in feature
    assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html
    assert "qs('#file-new-issue').hidden = enabled" in feature
    assert "qs('.create-issue-capture-actions').hidden = false" in feature
    assert "label:ready ? 'Ready to file' : 'Needs filing'" in feature
    assert "repository:plan.repository || ''" in feature
    assert "Saved planned Draft" in feature


@pytest.mark.anyio
async def test_mobile_ready_draft_card_opens_validated_final_review_in_one_tap():
    html = await dashboard()

    assert "isUnfiled ? item.action" in html
    assert "[item.label, item.repository, item.details].filter(Boolean).join(' · ')" in html
    assert "escapeHtml([item.label" in html
    assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html
    assert "capture:()=>issueCapture" in html
    assert "review:r=>loadIssueFilingMetadata(r.repository,r)" in html
    assert ".then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates)" in html
    assert "qs('#create-issue-form').requestSubmit(submitter)" in DRAFT_SESSION.read_text()
    assert "dFS.takeTrigger() || event.submitter || qs('#submit-new-issue')" in html
    assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }' in html
    assert '@media(max-width:320px)' in html


@pytest.mark.anyio
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
    html = await dashboard()

    assert '<script src="static/draft-filing-session.js"></script>' in html
    assert 'id="draft-filing-session"' in html
    assert 'id="draft-filing-progress"' in html
    assert 'id="previous-draft"' in html
    assert 'id="skip-draft"' in html
    assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html
    assert "await dFS.advance(fS.id)" in html
    feature = DRAFT_SESSION.read_text()
    assert "Draft ${state.position} of ${state.total}" in feature
    assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
    assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html
    assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html


@pytest.mark.anyio
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
    html = await dashboard()

    assert 'id="draft-capacity-sheet"' in html
    assert 'Drafts full — nothing was deleted.' in html
    assert 'id="review-full-drafts"' in html
    assert 'id="replace-oldest-draft"' in html
    assert 'id="keep-editing-draft"' in html
    assert "showDraftCapacityDialog(unfiledCaptures, qs)" in html
    assert "bindDraftCapacityDialog({" in html
    feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
    assert "const draft = currentIssueCaptureDraft();" in feature
    assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature
    assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
    assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
    assert '.draft-capacity-actions button { min-height:44px;' in html


@pytest.mark.anyio
async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_fields():
    html = await dashboard()

    assert 'id="create-issue-heading">Capture work' in html
    assert 'class="create-issue-filing" id="create-issue-filing" hidden' in html
    assert 'id="save-unfiled-issue" type="button">Save to Drafts' in html
    assert 'id="file-new-issue" type="button">File now' in html
    assert "function setIssueFilingMode(enabled)" in html
    assert "setIssueFilingMode(Boolean(captureDraft.repository))" in html
    assert "window.matchMedia?.('(max-width: 600px)').matches === true" in html
    assert "unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})" in html
    assert "qs('#file-new-issue').addEventListener('click'" in html
    assert '.create-issue-capture-actions button { min-height:44px;' in html
    assert '.create-issue-capture-actions[hidden] { display:none;' in html
    attachment = html.index('class="create-issue-attachment"')
    filing = html.index('class="create-issue-filing"')
    assert attachment < filing
