import json
import subprocess
from pathlib import Path


SOURCE = Path(__file__).parents[1] / "frontend" / "today-session-sync.js"


def run_node(script: str) -> dict:
    completed = subprocess.run(
        ["node", "-e", SOURCE.read_text() + "\n" + script],
        check=True,
        capture_output=True,
        text=True,
    )
    return json.loads(completed.stdout)


def test_second_device_sees_and_claims_the_running_session():
    result = run_node(
        r"""
const calls = [];
const remote = {revision:3, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:90000, running:true, updated_at:10};
let adopted = null;
const offers = [];
const sync = createTodaySessionSync({
  getDeviceId:()=>'phone-b',
  fetchJson:async (url, options={}) => {
    calls.push({url, body:options.body ? JSON.parse(options.body) : null});
    return options.method === 'PATCH' ? {...remote, revision:4, device_id:'phone-b'} : remote;
  },
  timer:{adopt:(identity, elapsed, running)=>{adopted={identity, elapsed, running};}},
  onRemote:session=>{offers.push(session);},
});
(async()=>{
  await sync.refresh();
  const claimed = await sync.claim();
  process.stdout.write(JSON.stringify({offers, claimed, adopted, calls}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result["offers"][0]["device_id"] == "desktop-a"
    assert result["claimed"]["device_id"] == "phone-b"
    assert result["adopted"] == {
        "identity": "issue:r:42:", "elapsed": 90000, "running": True,
    }
    assert result["calls"][1] == {
        "url": "api/v1/today/session",
        "body": {
            "base_revision": 3,
            "device_id": "phone-b",
            "identity": "issue:r:42:",
            "elapsed_ms": 90000,
            "running": True,
        },
    }


def test_second_device_sees_and_resumes_a_timed_break():
    result = run_node(
        r"""
const calls=[];
const remote={revision:3,device_id:'phone-a',identity:'issue:r:42:',elapsed_ms:90000,running:false,break_deadline_at:1800000,updated_at:10};
let adopted=null;
const offers=[];
const sync=createTodaySessionSync({
  getDeviceId:()=> 'desktop-b',
  fetchJson:async (url,options={})=>{
    calls.push(options.body ? JSON.parse(options.body) : null);
    return options.method === 'PATCH' ? {...remote,revision:4,device_id:'desktop-b',running:true,break_deadline_at:null} : remote;
  },
  timer:{adopt:(identity,elapsed,running)=>{adopted={identity,elapsed,running};}},
  onRemote:session=>offers.push(session),
});
(async()=>{
  await sync.refresh();
  const claimed=await sync.claim();
  process.stdout.write(JSON.stringify({offers,claimed,adopted,calls}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result["offers"][0]["break_deadline_at"] == 1_800_000
    assert result["calls"][1] == {
        "base_revision": 3, "device_id": "desktop-b", "identity": "issue:r:42:",
        "elapsed_ms": 90_000, "running": True, "break_deadline_at": None,
    }
    assert result["adopted"] == {
        "identity": "issue:r:42:", "elapsed": 90_000, "running": True,
    }
    assert result["claimed"]["device_id"] == "desktop-b"


def test_break_handoff_summary_is_actionable_and_privacy_safe():
    result = run_node(
        r"""
const session={identity:'issue:private/repo:42:',elapsed_ms:90000,running:false,break_deadline_at:1800000};
process.stdout.write(JSON.stringify({
  summary:todaySessionHandoffSummary(session,{title:'Secret issue'},0),
}));
"""
    )

    assert result["summary"].startswith("On break until ")
    assert "Secret issue" not in result["summary"]
    assert "private/repo" not in result["summary"]


def test_previous_owner_pauses_after_another_device_claims():
    result = run_node(
        r"""
let response = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
let pauses = 0;
let transferred = null;
const sync = createTodaySessionSync({
  getDeviceId:()=> 'desktop-a',
  fetchJson:async()=>response,
  timer:{pause:()=>{pauses += 1;}},
  onTransferred:session=>{transferred=session;},
});
(async()=>{
  await sync.refresh();
  response = {...response, revision:2, device_id:'phone-b', elapsed_ms:2500};
  await sync.refresh();
  await sync.refresh();
  process.stdout.write(JSON.stringify({pauses, transferred}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result["pauses"] == 1
    assert result["transferred"]["device_id"] == "phone-b"


def test_owner_poll_publishes_current_elapsed_time():
    result = run_node(
        r"""
const calls = [];
let tick = null;
let elapsed = 1000;
const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
const sync = createTodaySessionSync({
  getDeviceId:()=> 'desktop-a',
  fetchJson:async (url, options={}) => {
    calls.push({method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null});
    return options.method === 'PATCH' ? {...owned, revision:2, elapsed_ms:elapsed} : owned;
  },
  timer:{snapshot:()=>({identity:owned.identity, elapsed_ms:elapsed, running:true})},
  setInterval:callback=>{tick=callback; return 7;},
});
(async()=>{
  await sync.refresh();
  sync.start();
  elapsed = 6500;
  await tick();
  process.stdout.write(JSON.stringify(calls));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result[-1] == {
        "method": "PATCH",
        "body": {
            "base_revision": 1,
            "device_id": "desktop-a",
            "identity": "issue:r:42:",
            "elapsed_ms": 6500,
            "running": True,
        },
    }


def test_publish_conflict_adopts_remote_owner_and_pauses_once():
    result = run_node(
        r"""
const calls = [];
const owned = {revision:2, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
const remote = {...owned, revision:3, device_id:'phone-b', elapsed_ms:2500};
let pauses = 0;
const transfers = [];
const statuses = [];
let reads = 0;
const sync = createTodaySessionSync({
  getDeviceId:()=> 'desktop-a',
  fetchJson:async (url, options={}) => {
    calls.push(options.method || 'GET');
    if (options.method === 'PATCH') {
      const error = new Error('session changed');
      error.status = 409;
      error.code = 'session_changed';
      throw error;
    }
    reads += 1;
    return reads === 1 ? owned : remote;
  },
  timer:{
    snapshot:()=>({identity:owned.identity, elapsed_ms:1500, running:true}),
    pause:()=>{pauses += 1;},
  },
  onTransferred:session=>transfers.push(session.device_id),
  onStatus:status=>statuses.push(status),
});
(async()=>{
  await sync.refresh();
  await sync.publish();
  await sync.refresh();
  process.stdout.write(JSON.stringify({calls, pauses, transfers, statuses, session:sync.session()}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result["calls"] == ["GET", "PATCH", "GET", "GET"]
    assert result["session"]["device_id"] == "phone-b"
    assert result["pauses"] == 1
    assert result["transfers"] == ["phone-b"]
    assert "conflict" in result["statuses"]


def test_rapid_publishes_are_single_flight_and_coalesce_latest_snapshot():
    result = run_node(
        r"""
const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:0, running:true};
const bodies = [];
let active = 0;
let peak = 0;
let releaseFirst;
let patchCount = 0;
const firstPatch = new Promise(resolve=>{releaseFirst=resolve;});
const sync = createTodaySessionSync({
  getDeviceId:()=> 'desktop-a',
  fetchJson:async (url, options={}) => {
    if (!options.method) return owned;
    active += 1;
    peak = Math.max(peak, active);
    patchCount += 1;
    const body = JSON.parse(options.body);
    bodies.push(body);
    if (patchCount === 1) await firstPatch;
    active -= 1;
    return {...owned, revision:1 + patchCount, elapsed_ms:body.elapsed_ms, running:body.running};
  },
});
(async()=>{
  await sync.refresh();
  const first = sync.publish({identity:owned.identity, elapsed_ms:1000, running:true});
  await Promise.resolve();
  const middle = sync.publish({identity:owned.identity, elapsed_ms:2000, running:false});
  const latest = sync.publish({identity:owned.identity, elapsed_ms:3000, running:true});
  releaseFirst();
  await Promise.all([first, middle, latest]);
  process.stdout.write(JSON.stringify({peak, bodies, session:sync.session()}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result["peak"] == 1
    assert [body["elapsed_ms"] for body in result["bodies"]] == [1000, 3000]
    assert result["session"]["elapsed_ms"] == 3000


def test_remote_conflict_discards_queued_local_publishes_until_explicit_claim():
    result = run_node(
        r"""
const owned = {revision:2, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
const remote = {...owned, revision:3, device_id:'phone-b', elapsed_ms:2500};
const calls = [];
let reads = 0;
let releaseConflict;
const conflictReady = new Promise(resolve=>{releaseConflict=resolve;});
const sync = createTodaySessionSync({
  getDeviceId:()=> 'desktop-a',
  fetchJson:async (url, options={}) => {
    calls.push(options.method || 'GET');
    if (options.method === 'PATCH') {
      await conflictReady;
      const error = new Error('session changed');
      error.status = 409;
      throw error;
    }
    reads += 1;
    return reads === 1 ? owned : remote;
  },
  timer:{pause:()=>{}},
});
(async()=>{
  await sync.refresh();
  const stale = sync.publish({identity:owned.identity, elapsed_ms:1500, running:true});
  await Promise.resolve();
  const queued = sync.publish({identity:owned.identity, elapsed_ms:2000, running:true});
  releaseConflict();
  await Promise.all([stale, queued]);
  process.stdout.write(JSON.stringify({calls, session:sync.session()}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result["calls"] == ["GET", "PATCH", "GET"]
    assert result["session"]["device_id"] == "phone-b"


def test_transient_failure_retains_latest_intent_for_retry():
    result = run_node(
        r"""
const owned = {revision:4, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
const bodies = [];
const statuses = [];
let attempts = 0;
const sync = createTodaySessionSync({
  getDeviceId:()=> 'desktop-a',
  fetchJson:async (url, options={}) => {
    if (!options.method) return owned;
    attempts += 1;
    bodies.push(JSON.parse(options.body));
    if (attempts === 1) {
      const error = new Error('temporarily unavailable');
      error.status = 503;
      throw error;
    }
    return {...owned, revision:5, elapsed_ms:bodies.at(-1).elapsed_ms};
  },
  onStatus:status=>statuses.push(status),
});
(async()=>{
  await sync.refresh();
  const first = await sync.publish({identity:owned.identity, elapsed_ms:4500, running:true});
  const retried = await sync.publish();
  process.stdout.write(JSON.stringify({first, retried, bodies, statuses}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result["first"] is None
    assert [body["elapsed_ms"] for body in result["bodies"]] == [4500, 4500]
    assert result["retried"]["revision"] == 5
    assert "offline" in result["statuses"]


def test_claim_transport_failure_stays_retryable_without_false_conflict():
    result = run_node(
        r"""
const remote = {revision:7, device_id:'phone-b', identity:'issue:r:42:', elapsed_ms:9000, running:true};
const calls = [];
const statuses = [];
const sync = createTodaySessionSync({
  getDeviceId:()=> 'desktop-a',
  fetchJson:async (url, options={}) => {
    calls.push(options.method || 'GET');
    if (!options.method) return remote;
    const error = new Error('gateway unavailable');
    error.status = 503;
    throw error;
  },
  onStatus:status=>statuses.push(status),
});
(async()=>{
  await sync.refresh();
  const claimed = await sync.claim();
  process.stdout.write(JSON.stringify({claimed, calls, statuses}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
    )

    assert result["claimed"] is None
    assert result["calls"] == ["GET", "PATCH"]
    assert result["statuses"][-1] == "offline"
    assert "conflict" not in result["statuses"]
