import json
import re
import subprocess
from pathlib import Path


ATTACHMENT = Path(__file__).parents[1] / "frontend" / "issue-attachment.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
README = Path(__file__).parents[1] / "README.md"


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


def test_mobile_attachment_prepares_comment_and_reuses_confirmed_binary_upload():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const calls = [];
const file = new Blob(['png-bytes'],{{type:'image/png'}}); file.name='checkout.png';
const controller = attachment.create({{
  createOperationId: () => 'attachment-comment-471',
  readDataUrl: async () => {{ throw new Error('base64 conversion must not run'); }},
  upload: async payload => {{ calls.push({{
    repository:payload.repository,number:payload.number,filename:payload.filename,
    content_type:payload.content_type,isBlob:payload.blob instanceof Blob,
    text:await payload.blob.text(),operation_id:payload.operation_id
  }}); return {{markdown:'![checkout.png](https://forge.example/a.png)'}}; }},
}});
controller.select(file);
(async()=>{{
  const first = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
  const second = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
  process.stdout.write(JSON.stringify({{first,second,calls,state:controller.state()}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
    output = json.loads(run_node(script))
    assert output["first"] == (
        "Layout breaks\n\n![checkout.png](https://forge.example/a.png)"
    )
    assert output["second"] == output["first"]
    assert output["calls"] == [{
        "repository": "stackchain/api",
        "number": 17,
        "filename": "checkout.png",
        "content_type": "image/png",
        "isBlob": True,
        "text": "png-bytes",
        "operation_id": "attachment-comment-471",
    }]
    assert output["state"] == {
        "name": "checkout.png", "size": 9, "uploaded": True
    }


def test_mobile_evidence_bundle_keeps_order_limits_selection_and_uploads_each_image_once():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
let sequence=0; const calls=[];
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
  maxFiles:5,
  createOperationId:()=> 'evidence-' + (++sequence),
  upload:async payload=>{{calls.push([payload.filename,payload.operation_id]);return {{markdown:'!['+payload.filename+'](url/'+payload.filename+')'}};}},
}});
for(const name of ['one.png','two.png','three.png','four.png','five.png']) controller.select(image(name));
let limit='';try{{controller.select(image('six.png'));}}catch(error){{limit=error.message;}}
controller.remove(1);
controller.select(image('replacement.png'));
(async()=>{{
  const serialized=await controller.serialize();
  const comment=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
  const second=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
  process.stdout.write(JSON.stringify({{limit,state:controller.state(),names:serialized.map(x=>x.filename),comment,second,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))
    assert output["limit"] == "Up to 5 screenshots. Remove one before adding another."
    assert output["names"] == ["one.png", "three.png", "four.png", "five.png", "replacement.png"]
    assert [item["name"] for item in output["state"]] == output["names"]
    assert output["comment"] == output["second"]
    assert output["comment"].startswith("Evidence sequence\n\n![one.png]")
    assert len(output["calls"]) == 5
    assert len({key for _, key in output["calls"]}) == 5


def test_mobile_conversation_composers_accept_five_ordered_photos():
    html = INDEX.read_text()
    dashboard = DASHBOARD.read_text()

    for input_id in (
        "issue-attachment", "pull-attachment", "update-reply-attachment",
    ):
        tag = re.search(rf'<input[^>]+id="{input_id}"[^>]*>', html)
        assert tag, input_id
        assert " multiple" in tag.group(0), input_id

    for controller in (
        "issueAttachmentController", "pullAttachmentController",
        "updateReplyAttachmentController",
    ):
        mount = re.search(
            rf"const {controller} = issueAttachment\.mount\(\{{(.*?)\n  \}}\);",
            dashboard,
            re.DOTALL,
        )
        assert mount, controller
        assert "maxFiles: 5" in mount.group(1), controller


def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
    html = INDEX.read_text()
    dashboard = DASHBOARD.read_text()
    css = CSS.read_text()

    assert 'for="take-search-reply-photo">Take photo</label>' in html
    assert 'id="take-search-reply-photo" type="file" accept="image/*" capture="environment"' in html
    assert 'for="search-reply-attachment">Choose existing</label>' in html
    assert 'id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
    mount = re.search(
        r"const searchReplyAttachmentController = issueAttachment\.mount\(\{(.*?)\n  \}\);",
        dashboard,
        re.DOTALL,
    )
    assert mount
    assert "maxFiles: 5" in mount.group(1)
    assert "'/preview/attachments?kind='" in mount.group(1)
    assert "queueReply:async (item,body,operationId)" in dashboard
    assert "kind:'search-reply',targetKind:item.kind" in dashboard
    assert "authoredOutbox.enqueueDurably" in dashboard
    assert ".search-preview-reply .conversation-photo-actions" in css


def test_search_preview_can_send_photo_only_and_advances_after_confirmed_comment():
    search_preview = Path(__file__).parents[1] / "frontend" / "search-preview.js"
    script = f"""
const create = require({json.dumps(str(search_preview))});
const posted=[]; const prepared=[]; let cleared=0; let navigated=[]; let attached=true;
const item={{kind:'issue',repository:'stackchain/api',number:42}};
const next={{kind:'pull',repository:'stackchain/web',number:9}};
const api=create({{
  fetchJson:async value=>({{...value,commentable:true}}),
  postReply:async (target,body,key)=>{{posted.push([target.number,body,key]);return {{id:91,body}};}},
  prepareReply:async (target,body)=>{{prepared.push([target.number,body]);return '![photo](<safe-url>)';}},
  hasAttachments:()=>attached,
  clearAttachments:()=>{{cleared += 1;attached=false;}},
  storage:{{getItem:()=>'',setItem:()=>{{}},removeItem:()=>{{}}}},
  createOperationId:()=> 'reply-photo-42',
  getSession:()=>({{items:[item,next],more:false}}),
  onNavigate:value=>navigated.push(value.number),
  onState:()=>{{}},
}});
(async()=>{{
  await api.open(item);
  await api.reply({{advance:true}});
  process.stdout.write(JSON.stringify({{posted,prepared,cleared,navigated}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))
    assert output == {
        "posted": [[42, "![photo](<safe-url>)", "reply-photo-42"]],
        "prepared": [[42, ""]],
        "cleared": 1,
        "navigated": [9],
    }


def test_mobile_conversation_composers_review_reorder_remove_and_caption_every_photo():
    html = INDEX.read_text()
    dashboard = DASHBOARD.read_text()
    css = CSS.read_text()

    composers = (
        ("issue-attachment", "issueAttachmentController"),
        ("pull-attachment", "pullAttachmentController"),
        ("update-reply-attachment", "updateReplyAttachmentController"),
    )
    for attachment_id, controller in composers:
        assert f'id="{attachment_id}-tray"' in html
        assert f'id="{attachment_id}-note" maxlength="240"' in html
        assert f'id="move-{attachment_id}-earlier"' in html
        assert f'id="remove-{attachment_id}"' in html
        assert f'id="move-{attachment_id}-later"' in html
        mount = re.search(
            rf"const {controller} = issueAttachment\.mount\(\{{(.*?)\n  \}}\);",
            dashboard,
            re.DOTALL,
        )
        assert mount, controller
        assert f"tray: qs('#{attachment_id}-tray')" in mount.group(1)
        assert f"earlier: qs('#move-{attachment_id}-earlier')" in mount.group(1)
        assert f"later: qs('#move-{attachment_id}-later')" in mount.group(1)
        assert f"note: qs('#{attachment_id}-note')" in mount.group(1)

    assert ".conversation-evidence-review" in css
    assert ".conversation-evidence-review .issue-evidence-review-actions button" in css
    assert "min-height:44px" in css


def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const uploaded=[];
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
  maxFiles:5,
  upload:async payload=>{{uploaded.push(payload.filename);return {{markdown:'!['+payload.filename+'](url)'}};}},
}});
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
controller.move(2, 0);
controller.move(0, 99);
controller.move(0, 1);
(async()=>{{
  const serialized=await controller.serialize();
  const comment=await controller.prepareComment({{repository:'o/r',number:825}},'Reviewed');
  process.stdout.write(JSON.stringify({{names:serialized.map(x=>x.filename),uploaded,comment}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))
    assert output["names"] == ["one.png", "three.png", "two.png"]
    assert output["uploaded"] == output["names"]
    assert output["comment"].endswith(
        "![one.png](url)\n\n![three.png](url)\n\n![two.png](url)"
    )


def test_mobile_evidence_notes_follow_images_and_render_as_safe_ordered_captions():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
  maxFiles:5,
  upload:async payload=>({{markdown:'!['+payload.filename+'](url/'+payload.filename+')'}}),
}});
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
controller.setNote(0, '  Login *token* is visible  ');
controller.setNote(1, 'Keyboard hides [Submit](bad)');
controller.setNote(2, '');
controller.move(1, 0);
controller.remove(1);
(async()=>{{
  const serialized=await controller.serialize();
  const comment=await controller.prepareComment({{repository:'o/r',number:827}},'Evidence');
  process.stdout.write(JSON.stringify({{serialized:serialized.map(x=>({{filename:x.filename,note:x.note||''}})),comment}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))
    assert output["serialized"] == [
        {"filename": "two.png", "note": "Keyboard hides [Submit](bad)"},
        {"filename": "three.png", "note": ""},
    ]
    assert output["comment"] == (
        "Evidence\n\n**Screenshot 1 — Keyboard hides \\[Submit\\]\\(bad\\)**\n\n"
        "![two.png](url/two.png)\n\n![three.png](url/three.png)"
    )


def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const keys=[];
let sequence=0;
let fail=true;
const controller=attachment.create({{
  createOperationId:()=> 'attachment-' + (++sequence),
  readDataUrl:async file=>'data:'+file.type+';base64,iVBORw0KGgo=',
  upload:async payload=>{{keys.push(payload.operation_id);if(fail){{fail=false;throw new Error('offline');}}return {{markdown:'![ok](https://forge.example/a)'}};}},
}});
controller.select({{name:'first.png',type:'image/png',size:8}});
(async()=>{{
  try{{await controller.prepareComment({{repository:'stackchain/api',number:17}},'Evidence');}}catch(_error){{}}
  await controller.prepareComment({{repository:'stackchain/api',number:17}},'Evidence');
  controller.select({{name:'replacement.png',type:'image/png',size:8}});
  await controller.prepareComment({{repository:'stackchain/api',number:17}},'Replacement');
  process.stdout.write(JSON.stringify(keys));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""

    assert json.loads(run_node(script)) == [
        "attachment-1", "attachment-1", "attachment-2"
    ]


def test_selected_screenshot_serializes_as_binary_blob_without_base64_expansion():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const calls=[];
const file=new Blob(['binary-image'],{{type:'image/webp'}});
file.name='phone.webp';
const controller=attachment.create({{
  readDataUrl:async()=>{{throw new Error('base64 conversion must not run');}},
  upload:async()=>{{calls.push('upload');}},
}});
controller.select(file);
controller.serialize().then(async value=>process.stdout.write(JSON.stringify({{
  filename:value.filename,contentType:value.contentType,
  isBlob:value.blob instanceof Blob,size:value.blob.size,text:await value.blob.text(),calls
}})));
"""
    output = json.loads(run_node(script))

    assert output == {
        "filename": "phone.webp",
        "contentType": "image/webp",
        "isBlob": True,
        "size": 12,
        "text": "binary-image",
        "calls": [],
    }


def test_oversized_screenshot_is_optimized_before_preview_and_durable_serialization():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const original = new Blob([new Uint8Array(attachment.MAX_BYTES + 200)], {{type:'image/png'}});
original.name = 'phone.png';
const calls = [];
const controller = attachment.create({{
  optimizeImage: async file => {{
    calls.push({{name:file.name,type:file.type,size:file.size}});
    const optimized = new Blob(['optimized-png'], {{type:'image/png'}});
    optimized.name = file.name;
    return optimized;
  }},
  upload: async () => {{ throw new Error('must not upload during selection'); }},
}});
(async()=>{{
  await controller.select(original);
  const serialized = await controller.serialize();
  process.stdout.write(JSON.stringify({{
    calls,state:controller.state(),name:serialized.filename,
    contentType:serialized.contentType,size:serialized.blob.size,
    text:await serialized.blob.text(),sameBlob:serialized.blob===original,
  }}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))

    assert output == {
        "calls": [{
            "name": "phone.png",
            "type": "image/png",
            "size": 2 * 1024 * 1024 + 200,
        }],
        "state": {"name": "phone.png", "size": 13, "uploaded": False},
        "name": "phone.png",
        "contentType": "image/png",
        "size": 13,
        "text": "optimized-png",
        "sameBlob": False,
    }


def test_browser_optimizer_downscales_until_encoded_image_fits_limit():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const original = new Blob([new Uint8Array(attachment.MAX_BYTES + 1)], {{type:'image/jpeg'}});
original.name = 'camera.jpg';
const attempts = [];
let closed = false;
const canvas = {{
  width: 0, height: 0,
  getContext: () => ({{drawImage: () => {{}}}}),
  toBlob: callback => {{
    attempts.push([canvas.width, canvas.height]);
    const size = attempts.length === 1 ? attachment.MAX_BYTES + 50 : attachment.MAX_BYTES - 50;
    callback(new Blob([new Uint8Array(size)], {{type:'image/jpeg'}}));
  }},
}};
(async()=>{{
  const result = await attachment.optimizeImage(original, {{
    createImageBitmap: async () => ({{width:2000,height:1000,close:()=>{{closed=true;}}}}),
    createCanvas: () => canvas,
  }});
  process.stdout.write(JSON.stringify({{
    attempts,closed,size:result.size,type:result.type,name:result.name,
  }}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))

    assert len(output["attempts"]) == 2
    assert output["attempts"][1][0] < output["attempts"][0][0]
    assert output["attempts"][1][1] < output["attempts"][0][1]
    assert output["closed"] is True
    assert output["size"] == 2 * 1024 * 1024 - 50
    assert output["type"] == "image/jpeg"
    assert output["name"] == "camera.jpg"


def test_browser_optimizer_reencodes_bounded_images_to_strip_embedded_metadata():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const results = [];
(async()=>{{
  for (const type of ['image/jpeg', 'image/png', 'image/webp']) {{
    const original = new Blob(['visible-pixels\\nEXIF GPS device-secret'], {{type}});
    original.name = 'camera.' + type.split('/')[1];
    let draws = 0;
    const canvas = {{
      width: 0, height: 0,
      getContext: () => ({{drawImage: () => {{draws += 1;}}}}),
      toBlob: (callback, outputType) => callback(new Blob(['sanitized-pixels'], {{type:outputType}})),
    }};
    const result = await attachment.optimizeImage(original, {{
      createImageBitmap: async () => ({{width:640,height:480,close:()=>{{}}}}),
      createCanvas: () => canvas,
    }});
    results.push({{
      type:result.type,name:result.name,text:await result.text(),
      sameObject:result===original,draws,width:canvas.width,height:canvas.height,
    }});
  }}
  process.stdout.write(JSON.stringify(results));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))

    assert output == [
        {
            "type": image_type,
            "name": filename,
            "text": "sanitized-pixels",
            "sameObject": False,
            "draws": 1,
            "width": 640,
            "height": 480,
        }
        for image_type, filename in (
            ("image/jpeg", "camera.jpeg"),
            ("image/png", "camera.png"),
            ("image/webp", "camera.webp"),
        )
    ]


def test_browser_optimizer_downscales_small_file_when_decoded_pixels_exceed_budget():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const original = new Blob(['compressed'], {{type:'image/jpeg'}});
original.name = 'panorama.jpg';
let closed = false;
const attempts = [];
const canvas = {{
  width: 0, height: 0,
  getContext: () => ({{drawImage: () => {{}}}}),
  toBlob: callback => {{
    attempts.push([canvas.width, canvas.height]);
    callback(new Blob(['bounded'], {{type:'image/jpeg'}}));
  }},
}};
(async()=>{{
  const result = await attachment.optimizeImage(original, {{
    createImageBitmap: async () => ({{width:6000,height:4000,close:()=>{{closed=true;}}}}),
    createCanvas: () => canvas,
  }});
  process.stdout.write(JSON.stringify({{
    attempts,closed,size:result.size,type:result.type,name:result.name,
    maxPixels:attachment.MAX_PIXELS,
  }}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))

    assert len(output["attempts"]) == 1
    width, height = output["attempts"][0]
    assert width * height <= output["maxPixels"]
    assert width < 6000
    assert height < 4000
    assert output["closed"] is True
    assert output["size"] == 7
    assert output["type"] == "image/jpeg"
    assert output["name"] == "panorama.jpg"


def test_mobile_preview_inspects_small_screenshot_before_rendering_it():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}dispatch(t){{return this.listeners[t]({{target:this}});}}}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
const original=new Blob(['compressed'],{{type:'image/jpeg'}}); original.name='panorama.jpg';
const bounded=new Blob(['bounded'],{{type:'image/jpeg'}}); bounded.name='panorama.jpg';
const inspected=[];
attachment.mount({{
  input,preview,image,meta,remove,status,
  optimizeImage:async file=>{{inspected.push(file.name);return bounded;}},
  createObjectURL:blob=>blob===bounded?'blob:bounded':'blob:unsafe',revokeObjectURL:()=>{{}},upload:async()=>{{}},
}});
input.files=[original];
(async()=>{{
  await input.dispatch('change');
  process.stdout.write(JSON.stringify({{inspected,src:image.src,hidden:preview.hidden,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))

    assert output == {
        "inspected": ["panorama.jpg"],
        "src": "blob:bounded",
        "hidden": False,
        "status": "Screenshot optimized and ready to upload.",
    }


def test_binary_screenshot_builds_multipart_body_with_original_bytes():
    script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
const blob=new Blob(['original-bytes'],{{type:'image/png'}});
const form=attachment.multipart({{filename:'phone.png',contentType:'image/png',blob}});
const file=form.get('file');
(async()=>process.stdout.write(JSON.stringify({{
  filename:file.name,type:file.type,size:file.size,text:await file.text()
}})))();
"""

    assert json.loads(run_node(script)) == {
        "filename": "phone.png",
        "type": "image/png",
        "size": 14,
        "text": "original-bytes",
    }


def test_serialized_capture_attachment_can_be_restored_or_removed_while_editing():
    script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
const controller=attachment.create({{readDataUrl:async()=>{{throw new Error('must not reread');}},upload:async()=>{{}}}});
controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0KGgo='}});
(async()=>{{const restored={{state:controller.state(),value:await controller.serialize()}};controller.clear();process.stdout.write(JSON.stringify({{restored,removed:await controller.serialize()}}));}})();
"""
    output = json.loads(run_node(script))

    assert output["restored"]["state"]["name"] == "saved.png"
    assert output["restored"]["value"]["data"] == "iVBORw0KGgo="
    assert output["removed"] is None


def test_attachment_view_restores_ordered_evidence_bundle_preview():
    script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();input.multiple=true;
const controller=attachment.mount({{input,preview,image,meta,remove,status,createObjectURL:blob=>'blob:'+blob.size,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const value=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const restored=controller.restore([value('one.png'),value('two.png')]);
process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textContent,hidden:preview.hidden}}));
"""
    output = json.loads(run_node(script))
    assert [item["name"] for item in output["restored"]] == ["one.png", "two.png"]
    assert output["src"] == "blob:7"
    assert output["meta"] == "2 screenshots ready · latest: two.png"
    assert output["hidden"] is False



def test_evidence_review_tray_selects_reorders_and_removes_any_screenshot():
    script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{
  constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};this.dataset={{}};}}
  addEventListener(type,fn){{this.listeners[type]=fn;}}
  dispatch(type){{return this.listeners[type]({{target:this}});}}
  appendChild(child){{this.children.push(child);return child;}}
  replaceChildren(...children){{this.children=children;}}
  setAttribute(name,value){{this.attributes[name]=String(value);}}
}}
const document={{createElement:tag=>new Element(tag)}};
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button');
input.multiple=true;
const urls=[];
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
  removedMessage:'Screenshot removed. Your issue draft is unchanged.',
  optimizeImage:async file=>file,
  createObjectURL:blob=>{{const url='blob:'+blob.name;urls.push(url);return url;}},revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
input.files=['one.png','two.png','three.png'].map(file);
(async()=>{{
  await input.dispatch('change');
  await tray.children[1].dispatch('click');
  await earlier.dispatch('click');
  const reordered=(await controller.serialize()).map(item=>item.filename);
  await remove.dispatch('click');
  const remaining=(await controller.serialize()).map(item=>item.filename);
  process.stdout.write(JSON.stringify({{reordered,remaining,preview:image.src,meta:meta.textContent,
    selected:tray.children.map(button=>button.attributes['aria-pressed']),
    labels:tray.children.map(button=>button.attributes['aria-label']),
    earlierDisabled:earlier.disabled,laterDisabled:later.disabled,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))
    assert output["reordered"] == ["two.png", "one.png", "three.png"]
    assert output["remaining"] == ["one.png", "three.png"]
    assert output["preview"] == "blob:one.png"
    assert output["meta"] == "Screenshot 1 of 2 · one.png · 1 KB"
    assert output["selected"] == ["true", "false"]
    assert output["labels"] == [
        "Review screenshot 1 of 2: one.png", "Review screenshot 2 of 2: three.png"
    ]
    assert output["earlierDisabled"] is True
    assert output["laterDisabled"] is False
    assert output["status"] == "Screenshot removed. Your issue draft is unchanged."


def test_evidence_review_edits_the_active_note_and_keeps_it_visible_after_reorder():
    script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{
  constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};}}
  addEventListener(type,fn){{this.listeners[type]=fn;}}
  dispatch(type){{return this.listeners[type]({{target:this}});}}
  appendChild(child){{this.children.push(child);return child;}}
  replaceChildren(...children){{this.children=children;}}
  setAttribute(name,value){{this.attributes[name]=String(value);}}
}}
const document={{createElement:tag=>new Element(tag)}};
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button'),note=new Element('textarea'),noteLabel=new Element('label');
input.multiple=true;
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,note,noteLabel,document,
  optimizeImage:async file=>file,
  createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
input.files=['one.png','two.png','three.png'].map(file);
(async()=>{{
  await input.dispatch('change');
  await tray.children[1].dispatch('click');
  note.value='Keyboard hides Submit';
  await note.dispatch('input');
  await earlier.dispatch('click');
  const afterMove={{value:note.value,label:noteLabel.textContent,disabled:note.disabled}};
  await tray.children[1].dispatch('click');
  const other={{value:note.value,label:noteLabel.textContent}};
  process.stdout.write(JSON.stringify({{afterMove,other,serialized:(await controller.serialize()).map(x=>[x.filename,x.note||''])}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))
    assert output == {
        "afterMove": {
            "value": "Keyboard hides Submit",
            "label": "Evidence note for screenshot 1 of 3 (optional)",
            "disabled": False,
        },
        "other": {
            "value": "",
            "label": "Evidence note for screenshot 2 of 3 (optional)",
        },
        "serialized": [
            ["two.png", "Keyboard hides Submit"],
            ["one.png", ""],
            ["three.png", ""],
        ],
    }


def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
    script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
const controller=attachment.create({{readDataUrl:async()=>'',upload:async()=>{{}}}});
try {{
  controller.restore({{filename:'saved.png',contentType:'image/png',stored:true}});
  process.stdout.write(JSON.stringify({{restored:true,state:controller.state()}}));
}} catch (error) {{
  process.stdout.write(JSON.stringify({{restored:false,message:error.message,state:controller.state()}}));
}}
"""
    output = json.loads(run_node(script))

    assert output["restored"] is False
    assert "saved screenshot" in output["message"].lower()
    assert output["state"] is None


def test_issue_composer_renders_thumb_reachable_screenshot_preview():
    html = INDEX.read_text()
    css = CSS.read_text()

    assert 'id="issue-attachment"' in html
    assert 'type="file"' in html
    assert 'accept="image/png,image/jpeg,image/webp"' in html
    assert 'for="issue-attachment"' in html
    assert 'id="issue-attachment-preview"' in html
    assert 'id="remove-issue-attachment"' in html
    assert '.issue-attachment-trigger' in css
    assert '.issue-attachment-preview' in css
    assert 'min-height:44px' in css


def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
    html = INDEX.read_text()
    source = DASHBOARD.read_text()
    css = CSS.read_text()

    assert 'id="create-issue-attachment"' in html
    assert 'accept="image/png,image/jpeg,image/webp"' in html
    assert 'multiple' in html
    assert 'Up to 5' in html
    assert 'id="create-issue-attachment-preview"' in html
    assert 'id="remove-create-issue-attachment"' in html
    assert 'id="create-issue-evidence-tray"' in html
    assert 'role="toolbar"' in html
    assert 'aria-label="Photo evidence"' in html
    assert 'id="move-create-issue-attachment-earlier"' in html
    assert 'id="move-create-issue-attachment-later"' in html
    assert 'id="create-issue-evidence-note-label"' in html
    assert 'id="create-issue-evidence-note"' in html
    assert 'maxlength="240"' in html
    assert "tray: qs('#create-issue-evidence-tray')" in source
    assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
    assert "later: qs('#move-create-issue-attachment-later')" in source
    assert "note: qs('#create-issue-evidence-note')" in source
    assert "noteLabel: qs('#create-issue-evidence-note-label')" in source
    assert "const createIssueAttachmentController = issueAttachment.mount({" in source
    assert "attachments:evidence" in source
    assert "hydrated.attachments || hydrated.attachment" in source
    assert "createIssueAttachmentController.clear();" in source
    assert ".create-issue-attachment" in css
    assert ".issue-evidence-tray" in css
    assert ".issue-evidence-review-actions" in css
    assert ".issue-evidence-note" in css
    assert "resize:vertical" in css
    assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
    assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
    assert "overflow-x:auto" in css
    assert "min-width:44px" in css
    assert "overflow-x:hidden" in css


def test_mobile_new_issue_camera_and_library_render_as_one_photo_evidence_bundle():
    html = INDEX.read_text()
    source = DASHBOARD.read_text()
    css = CSS.read_text()

    assert 'id="take-create-issue-photo"' in html
    assert 'accept="image/*" capture="environment"' in html
    assert 'for="take-create-issue-photo">Take photo</label>' in html
    assert 'for="create-issue-attachment">Choose existing</label>' in html
    assert 'id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
    assert "inputs: [qs('#take-create-issue-photo'), qs('#create-issue-attachment')]" in source
    controls = css.split(".photo-evidence-actions", 1)[1].split("}", 1)[0]
    assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
    assert "min-width:0" in controls


def test_mobile_conversation_composers_offer_camera_and_library_through_one_attachment_controller():
    html = INDEX.read_text()
    source = DASHBOARD.read_text()
    css = CSS.read_text()

    composers = (
        ("take-issue-comment-photo", "issue-attachment", "issueAttachmentController"),
        ("take-pull-comment-photo", "pull-attachment", "pullAttachmentController"),
        ("take-update-reply-photo", "update-reply-attachment", "updateReplyAttachmentController"),
    )
    for camera_id, library_id, controller in composers:
        assert f'id="{camera_id}" type="file" accept="image/*" capture="environment"' in html
        assert f'for="{camera_id}">Take photo</label>' in html
        assert f'for="{library_id}">Choose existing</label>' in html
        mount = source.split(f"const {controller} = issueAttachment.mount({{", 1)[1].split("});", 1)[0]
        assert f"inputs: [qs('#{camera_id}'), qs('#{library_id}')]" in mount

    controls = css.split(".conversation-photo-actions", 1)[1].split("}", 1)[0]
    assert "grid-template-columns:repeat(2,minmax(0,1fr))" in controls
    assert "min-width:0" in controls
    assert ".conversation-photo-actions .issue-attachment-trigger" in css
    assert "width:100%" in css.split(".conversation-photo-actions .issue-attachment-trigger", 1)[1].split("}", 1)[0]

    script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element {{
  constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
  addEventListener(type,fn) {{ this.listeners[type]=fn; }}
  dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const camera=new Element(),library=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
library.multiple=false;
const controller=attachment.mount({{
  input:library,inputs:[camera,library],preview,image,meta,remove,status,
  optimizeImage:async file=>file,
  createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}},
}});
const photo=name=>{{const blob=new Blob([name],{{type:'image/jpeg'}});blob.name=name;return blob;}};
;(async()=>{{
  camera.files=[photo('camera-one.jpg')]; await camera.dispatch('change');
  const afterCamera=controller.state();
  camera.files=[]; await camera.dispatch('change');
  const afterCancel=controller.state();
  library.files=[photo('library-two.jpg')]; await library.dispatch('change');
  process.stdout.write(JSON.stringify({{afterCamera,afterCancel,afterLibrary:controller.state()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))
    assert output["afterCamera"]["name"] == "camera-one.jpg"
    assert output["afterCancel"] == output["afterCamera"]
    assert output["afterLibrary"]["name"] == "library-two.jpg"


def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable():
    source = DASHBOARD.read_text()
    handler = re.search(
        r"list\.querySelectorAll\('\.draft-edit'\).*?addEventListener\('click', async \(\) => \{(?P<body>.*?)\n      \}\);",
        source,
        re.DOTALL,
    )
    assert handler is not None
    body = handler.group("body")
    assert "await issueOutbox.hydrateForEdit(queued.id)" in body
    assert body.index("await issueOutbox.hydrateForEdit(queued.id)") < body.index(
        "openCreateIssueSheet()"
    )
    assert "catch (error)" in body
    assert "saved screenshot" in body.lower()


def test_attachment_view_keeps_invalid_draft_and_removes_preview():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
class Element {{
  constructor() {{ this.listeners={{}}; this.hidden=true; this.value=''; this.files=[]; this.textContent=''; this.src=''; }}
  addEventListener(type, fn) {{ this.listeners[type]=fn; }}
  dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const input=new Element(), preview=new Element(), image=new Element(), meta=new Element(), remove=new Element(), status=new Element();
const revoked=[];
const controller=attachment.mount({{
  input,preview,image,meta,remove,status,
  optimizeImage:async file=>file,
  createObjectURL:()=> 'blob:preview', revokeObjectURL:url=>revoked.push(url),
  readDataUrl:async()=>'', upload:async()=>{{}},
}});
;(async()=>{{
  input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; await input.dispatch('change');
  const invalid={{message:status.textContent,hidden:preview.hidden}};
  input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; await input.dispatch('change');
  const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
  await remove.dispatch('click');
  process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))
    assert output["invalid"] == {
        "message": "Choose a PNG, JPEG, or WebP screenshot.", "hidden": True
    }
    assert output["selected"]["src"] == "blob:preview"
    assert output["selected"]["hidden"] is False
    assert "screen.png" in output["selected"]["meta"]
    assert output["removed"] == {
        "hidden": True, "src": "", "revoked": ["blob:preview"], "state": None
    }


def test_oversized_attachment_view_shows_progress_then_previews_optimized_blob():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
class Element {{
  constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
  addEventListener(type,fn) {{ this.listeners[type]=fn; }}
  dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
const original=new Blob([new Uint8Array(attachment.MAX_BYTES+10)],{{type:'image/png'}}); original.name='large.png';
const optimized=new Blob(['small'],{{type:'image/png'}}); optimized.name='large.png';
let finish;
const gate=new Promise(resolve=>{{finish=resolve;}});
const previewed=[];
attachment.mount({{
  input,preview,image,meta,remove,status,
  optimizeImage:async()=>{{await gate;return optimized;}},
  createObjectURL:blob=>{{previewed.push(blob===optimized);return 'blob:optimized';}},
  revokeObjectURL:()=>{{}},upload:async()=>{{}},
}});
input.files=[original];
const pending=input.dispatch('change');
const during={{message:status.textContent,disabled:input.disabled,hidden:preview.hidden}};
finish();
Promise.resolve(pending).then(()=>process.stdout.write(JSON.stringify({{
  during,after:{{message:status.textContent,disabled:input.disabled,hidden:preview.hidden,
  src:image.src,meta:meta.textContent}},previewed
}}))).catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))

    assert output["during"] == {
        "message": "Optimizing screenshot…", "disabled": True, "hidden": True
    }
    assert output["after"]["message"] == "Screenshot optimized and ready to upload."
    assert output["after"]["disabled"] is False
    assert output["after"]["hidden"] is False
    assert output["after"]["src"] == "blob:optimized"
    assert "large.png" in output["after"]["meta"]
    assert output["previewed"] == [True]


def test_removing_screenshot_during_optimization_cancels_stale_selection():
    script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
class Element {{
  constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
  addEventListener(type,fn) {{ this.listeners[type]=fn; }}
  dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
const original=new Blob([new Uint8Array(attachment.MAX_BYTES+10)],{{type:'image/webp'}}); original.name='large.webp';
const optimized=new Blob(['small'],{{type:'image/webp'}}); optimized.name='large.webp';
let finish;
const gate=new Promise(resolve=>{{finish=resolve;}});
const controller=attachment.mount({{
  input,preview,image,meta,remove,status,
  optimizeImage:async()=>{{await gate;return optimized;}},
  createObjectURL:()=> 'blob:stale',revokeObjectURL:()=>{{}},upload:async()=>{{}},
}});
input.files=[original];
const pending=input.dispatch('change');
remove.dispatch('click');
finish();
Promise.resolve(pending).then(async()=>process.stdout.write(JSON.stringify({{
  state:controller.state(),serialized:await controller.serialize(),disabled:input.disabled,
  hidden:preview.hidden,src:image.src,status:status.textContent,
}}))).catch(error=>{{console.error(error);process.exit(1);}});
"""
    output = json.loads(run_node(script))

    assert output == {
        "state": None,
        "serialized": None,
        "disabled": False,
        "hidden": True,
        "src": "",
        "status": "Screenshot removed. Your comment is unchanged.",
    }


def test_pending_comment_admission_locks_attachment_replacement_and_remove_controls():
    script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element {{
  constructor() {{ this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}}; }}
  addEventListener(type,fn) {{ this.listeners[type]=fn; }}
  appendChild(child) {{ this.children.push(child); }}
  replaceChildren(...children) {{ this.children=children; }}
  setAttribute(name,value) {{ this.attributes[name]=String(value); }}
}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element(),tray=new Element(),earlier=new Element(),later=new Element();
input.multiple=true;
const document={{createElement:()=>new Element()}};
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
  createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
controller.restore([
  {{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
  {{filename:'two.png',contentType:'image/png',blob:new Blob(['two'],{{type:'image/png'}})}},
]);
controller.setBusy(true);
const pending={{input:input.disabled,remove:remove.disabled,earlier:earlier.disabled,later:later.disabled,
  thumbnails:tray.children.map(button=>button.disabled)}};
controller.setBusy(false);
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled,
  earlier:earlier.disabled,later:later.disabled,thumbnails:tray.children.map(button=>button.disabled)}}}}));
"""

    assert json.loads(run_node(script)) == {
        "pending": {
            "input": True, "remove": True, "earlier": True, "later": True,
            "thumbnails": [True, True],
        },
        "released": {
            "input": False, "remove": False, "earlier": False, "later": True,
            "thumbnails": [False, False],
        },
    }


def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_after_acceptance():
    source = DASHBOARD.read_text()

    assert "issueAttachment.mount({" in source
    assert "'/attachments'" in source
    assert "issueAttachmentController.prepareComment(item, body)" in source
    assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
    assert "await conversationPhotoDrafts.complete('issue')" in source
    assert "await conversationPhotoDrafts.complete(kind)" in source
    assert "'Idempotency-Key': payload.operation_id" in source
    assert "body: issueAttachment.multipart(payload)" in source


def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
    source = DASHBOARD.read_text()
    close_body = re.search(
        r"function closeIssueSheet\(navigate = true\) \{(?P<body>.*?)\n  \}",
        source,
        re.DOTALL,
    ).group("body")
    assert "conversationPhotoDrafts.leave('issue')" in close_body


def test_attachment_runtime_is_available_in_the_offline_app_shell():
    assert "static/issue-attachment.js" in SERVICE_WORKER.read_text()


def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
    readme = README.read_text()
    assert "PNG, JPEG, or WebP" in readme
    assert "2 MB" in readme
    assert "automatically optimizes oversized screenshots" in readme
    assert "uploads before the comment is posted" in readme
    assert "New issue" in readme
    assert "retry resumes with the confirmed issue" in readme
    assert "thumbnail tray" in readme
    assert "Move earlier" in readme
    assert "Remove selected" in readme
    assert "Evidence note" in readme
    assert "caption" in readme


def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
    source = DASHBOARD.read_text()

    assert "async function queueIssueScreenshotComment" in source
    assert "attachment: await issueAttachmentController.serialize()" in source
    assert "await authoredOutbox.enqueueDurably(message)" in source
    assert "await issueCommentNext.admit(item, message)" in source
    assert "navigator.onLine === false" in source


def test_assigned_pull_composer_offers_screenshot_preview_remove_and_reuses_optimizer():
    html = INDEX.read_text()
    source = DASHBOARD.read_text()

    assert 'id="pull-attachment"' in html
    assert 'accept="image/png,image/jpeg,image/webp"' in html
    assert 'for="pull-attachment"' in html
    assert 'id="pull-attachment-preview"' in html
    assert 'id="pull-attachment-image"' in html
    assert 'id="remove-pull-attachment"' in html
    assert "const pullAttachmentController = issueAttachment.mount({" in source
    assert "await conversationPhotoDrafts.complete('pull')" in source


def test_retrying_same_pull_load_restores_only_the_exact_target_draft():
    source = DASHBOARD.read_text()
    open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split(
        "\n  function closePullSheet", 1
    )[0]

    assert "conversationPhotoDrafts.switchTo('pull'," in open_body
    assert "() => selectedPull === item" in open_body


def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
    source = DASHBOARD.read_text()

    assert "async function queuePullScreenshotComment" in source
    assert "attachment: await pullAttachmentController.serialize()" in source
    assert "await authoredOutbox.enqueueDurably(message)" in source
    assert "navigator.onLine === false" in source
    assert "Your comment and screenshot are safe; retry." in source


def test_pull_screenshot_foreground_and_replay_share_the_durable_operation_pipeline():
    source = DASHBOARD.read_text()
    standard = source.split(
        "qs('#send-pull-comment').addEventListener('click', async () => {", 1
    )[1].split("\n  });", 1)[0]
    comment_next = source.split("async function submitCommentAndNext(kind) {", 1)[1].split(
        "\n  }\n  qs('#send-issue-comment-next')", 1
    )[0]

    assert "await queuePullScreenshotComment(item, body, operationId, false, true)" in standard
    assert "pullAttachmentController.prepareComment" not in standard
    assert "attachmentController.state() && (kind === 'pull' || navigator.onLine === false)" in comment_next
