import json
import subprocess
from pathlib import Path


SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"


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_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete():
    script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}};
function api(url,init={{}}) {{
  if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote)));
  const body=JSON.parse(init.body);
  if (body.revision!==remote.revision) {{
    const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error);
  }}
  remote={{revision:remote.revision+1,drafts:body.drafts}};
  return Promise.resolve(JSON.parse(JSON.stringify(remote)));
}}
function device(initial=[]) {{
  let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[];
  const captures={{
    exportOwned:async()=>drafts.map(item=>({{...item}})),
    mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}},
    discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
  }};
  const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
  const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}});
  return {{sync,states,drafts:()=>drafts}};
}}
(async()=>{{
  const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]);
  await phone.sync.sync('timmy');
  const desktop=device();
  await desktop.sync.sync('timmy');
  await desktop.sync.remove('field','timmy');
  await phone.sync.sync('timmy');
  process.stdout.write(JSON.stringify({{
    remote,desktop:desktop.drafts(),phone:phone.drafts(),
    phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status),
  }}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output["remote"]["revision"] == 2
    assert output["remote"]["drafts"] == []
    assert output["desktop"] == []
    assert output["phone"] == []
    assert output["phoneStates"][-1] == "ready"
    assert output["desktopStates"][-1] == "ready"


def test_unfiled_draft_sync_rebases_and_retries_a_revision_race_without_user_action():
    script = f"""
const createSync=require({json.dumps(str(SYNC))});
let calls=0, puts=0;const states=[];
let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}];
let remote={{revision:1,drafts:[]}};
const sync=createSync({{
  captures:{{
    exportOwned:async()=>drafts,
    reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
    discard:async()=>true,
  }},
  storage:{{getItem:()=>null,setItem:()=>{{}}}},
  fetchJson:async(_url,init)=>{{
    calls++;
    if(!init)return JSON.parse(JSON.stringify(remote));
    puts++;
    const body=JSON.parse(init.body);
    if(puts===1){{
      remote={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}};
      const error=new Error('conflict');error.status=409;
      error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(remote))}}}};
      throw error;
    }}
    if(body.revision!==remote.revision)throw new Error('stale retry');
    remote={{revision:remote.revision+1,drafts:body.drafts}};
    return JSON.parse(JSON.stringify(remote));
  }},
  onState:state=>states.push(state),
}});
(async()=>{{
  const result=await sync.sync('timmy');
  process.stdout.write(JSON.stringify({{calls,puts,states,result,drafts,remote}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output["result"] is True
    assert output["puts"] == 2
    assert output["calls"] == 4
    assert [item["id"] for item in output["remote"]["drafts"]] == ["remote", "local"]
    assert output["drafts"] == output["remote"]["drafts"]
    assert output["states"][-1]["status"] == "ready"


def test_unfiled_draft_sync_bounds_conflicts_and_exposes_a_retry_path():
    script = f"""
const createSync=require({json.dumps(str(SYNC))});
let contended=true, puts=0;
let drafts=[{{id:'local',title:'Local',body:'kept',saved_at:1}}];
let remote={{revision:1,drafts:[]}};const states=[];
const controller=createSync({{
  captures:{{
    exportOwned:async()=>JSON.parse(JSON.stringify(drafts)),
    reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
    discard:async()=>true,
  }},
  storage:{{getItem:()=>null,setItem:()=>{{}}}},getLogin:()=> 'timmy',maxConflictRetries:1,
  fetchJson:async(_url,init)=>{{
    if(!init)return JSON.parse(JSON.stringify(remote));
    puts++;const body=JSON.parse(init.body);
    if(contended){{
      remote={{revision:remote.revision+1,drafts:remote.drafts}};
      const error=new Error('conflict');error.status=409;throw error;
    }}
    remote={{revision:remote.revision+1,drafts:body.drafts}};
    return JSON.parse(JSON.stringify(remote));
  }},
  onState:state=>states.push(state),
}});
(async()=>{{
  let rejected=false;
  try{{await controller.sync('timmy')}}catch(error){{rejected=error.status===409}}
  const afterExhaustion={{puts,rejected,state:states.at(-1),drafts:JSON.parse(JSON.stringify(drafts))}};
  contended=false;
  const retried=await controller.retry();
  process.stdout.write(JSON.stringify({{afterExhaustion,retried,puts,state:states.at(-1),drafts,remote}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output["afterExhaustion"]["puts"] == 2
    assert output["afterExhaustion"]["rejected"] is True
    assert output["afterExhaustion"]["state"] == {
        "status": "conflict",
        "message": "Draft sync needs attention. Retry when your devices finish saving.",
        "retryable": True,
    }
    assert output["afterExhaustion"]["drafts"][0]["body"] == "kept"
    assert output["retried"] is True
    assert output["state"]["status"] == "ready"
    assert output["drafts"] == output["remote"]["drafts"]


def test_mounted_unfiled_draft_sync_shows_a_working_retry_action_after_exhaustion():
    assert 'id="retry-unfiled-draft-sync"' in HTML.read_text()
    script = f"""
let clickHandler;const status={{textContent:''}};
const button={{hidden:true,disabled:false,textContent:'Retry Draft sync',addEventListener:(_name,handler)=>clickHandler=handler}};
global.document={{querySelector:selector=>selector==='#retry-unfiled-draft-sync'?button:(selector==='#my-work-action-status'?status:null)}};
global.localStorage={{getItem:()=>null,setItem:()=>{{}}}};
global.setTimeout=()=>0;global.addEventListener=()=>{{}};
const createSync=require({json.dumps(str(SYNC))});
let contended=true;let remote={{revision:0,drafts:[]}};
const captures={{
  currentLogin:()=> 'timmy',exportOwned:async()=>[{{id:'local',title:'Local',body:'',saved_at:1}}],
  reconcileRemote:async()=>{{}},discard:async()=>true,
}};
const api=async(_url,init)=>{{
  if(!init)return remote;
  if(contended){{remote={{revision:remote.revision+1,drafts:[]}};const error=new Error('conflict');error.status=409;throw error}}
  const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
}};
(async()=>{{
  const controller=createSync.mount(captures,api);
  try{{await controller.sync('timmy')}}catch(_error){{}}
  const exhausted={{hidden:button.hidden,label:button.textContent,status:status.textContent}};
  contended=false;
  await clickHandler();
  process.stdout.write(JSON.stringify({{exhausted,after:{{hidden:button.hidden,disabled:button.disabled,status:status.textContent}},remote}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output["exhausted"] == {
        "hidden": False,
        "label": "Retry Draft sync",
        "status": "Draft sync needs attention. Retry when your devices finish saving.",
    }
    assert output["after"]["hidden"] is True
    assert output["after"]["disabled"] is False
    assert [draft["id"] for draft in output["remote"]["drafts"]] == ["local"]


def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict():
    script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release;
const gate=new Promise(resolve=>release=resolve);
const api=async(_url,init)=>{{
  if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}}
  puts++;const body=JSON.parse(init.body);
  if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}}
  remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
}};
const draft={{id:'local',title:'Local',body:'',saved_at:1}};
const sync=createSync({{
  captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}},
  storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api,
}});
(async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})();
"""
    output = run_node(script)

    assert output["puts"] == 1
    assert output["remote"]["drafts"][0]["id"] == "local"
    assert output["results"] == [True, True]


def test_unfiled_draft_sync_does_not_drop_an_account_transition_during_an_active_sync():
    script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}}, release;
const gate=new Promise(resolve=>release=resolve);let firstGet=true;const exported=[];
const api=async(_url,init)=>{{
  if(!init){{if(firstGet){{firstGet=false;await gate}}return JSON.parse(JSON.stringify(remote))}}
  const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return JSON.parse(JSON.stringify(remote));
}};
const captures={{
  exportOwned:async owner=>{{exported.push(owner);return [{{id:owner,title:owner,body:'',saved_at:owner==='alice'?1:2}}]}},
  reconcileRemote:async()=>{{}},discard:async()=>true,
}};
const sync=createSync({{captures,fetchJson:api,storage:{{getItem:()=>null,setItem:()=>{{}}}}}});
(async()=>{{
  const alice=sync.sync('alice');const bob=sync.sync('bob');release();
  const results=await Promise.all([alice,bob]);
  process.stdout.write(JSON.stringify({{exported,results,remote}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output["exported"] == ["alice", "bob"]
    assert output["results"] == [True, True]
    assert [draft["id"] for draft in output["remote"]["drafts"]] == ["bob", "alice"]


def test_unfiled_draft_sync_preserves_divergent_same_draft_edits_as_a_conflict_copy():
    script = f"""
const createSync=require({json.dumps(str(SYNC))});
let remote={{revision:0,drafts:[]}};
async function api(_url,init) {{
  if(!init) return JSON.parse(JSON.stringify(remote));
  const body=JSON.parse(init.body);
  if(body.revision!==remote.revision) {{
    const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error;
  }}
  remote={{revision:remote.revision+1,drafts:body.drafts}};
  return JSON.parse(JSON.stringify(remote));
}}
function device(initial=[]) {{
  let drafts=JSON.parse(JSON.stringify(initial));
  const values=new Map(), states=[];
  const captures={{
    exportOwned:async()=>JSON.parse(JSON.stringify(drafts)),
    mergeRemote:async items=>{{for(const item of items)if(!drafts.some(x=>x.id===item.id))drafts.push(JSON.parse(JSON.stringify(item))) }},
    reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
    discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
  }};
  const sync=createSync({{
    captures,fetchJson:api,
    storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
    createConflictId:()=> 'field-conflict-phone',onState:state=>states.push(state),
  }});
  return {{sync,states,edit(body,saved_at){{drafts[0].body=body;drafts[0].saved_at=saved_at}},drafts:()=>drafts}};
}}
(async()=>{{
  const original={{id:'field',title:'Field failure',body:'original',saved_at:1}};
  const phone=device([original]);
  await phone.sync.sync('timmy');
  const desktop=device();
  await desktop.sync.sync('timmy');
  desktop.edit('desktop edit',2);
  await desktop.sync.sync('timmy');
  await phone.sync.sync('timmy');
  phone.edit('phone edit',3);
  desktop.edit('desktop second edit',4);
  await desktop.sync.sync('timmy');
  await phone.sync.sync('timmy');
  process.stdout.write(JSON.stringify({{remote,phone:phone.drafts(),states:phone.states}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
    output = run_node(script)

    assert output["remote"]["revision"] == 4
    assert [(item["id"], item["body"]) for item in output["remote"]["drafts"]] == [
        ("field", "desktop second edit"),
        ("field-conflict-phone", "phone edit"),
    ]
    assert output["remote"]["drafts"][1]["title"] == "Field failure (conflict copy)"
    assert output["phone"] == output["remote"]["drafts"]
    assert output["states"][-1] == {
        "status": "conflict-preserved",
        "message": "Draft changed on two devices · both versions were saved.",
    }
