import json
import subprocess
from pathlib import Path


BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
FIND_WORK_NAV = Path(__file__).parents[1] / "frontend" / "mobile-find-work-nav.js"


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


def test_batch_preflights_today_capacity_before_claiming_any_issue():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
  capacity:()=>1,
  claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
  queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
  onProgress:progress=>calls.push('progress:'+progress.status),
}});
flow.run([
  {{repository:'stackchain/dashboard',number:671}},
  {{repository:'stackchain/dashboard',number:672}},
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""

    assert run_node(script) == {
        "result": {
            "status": "full",
            "selected": 2,
            "available": 1,
            "queued": [],
            "failed": [],
        },
        "calls": ["progress:full"],
    }


def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
  capacity:()=>3,
  claim:item=>{{
    calls.push('claim:'+item.number);
    return item.number===672 ? Promise.reject(new Error('already claimed')) :
      Promise.resolve({{...item,assignees:['timmy']}});
  }},
  queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
  onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
}});
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
  .then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""

    assert run_node(script) == {
        "result": {
            "status": "complete",
            "selected": 3,
            "available": 3,
            "queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
            "failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
        },
        "calls": [
            "claim:671", "queue:671", "progress:running:1",
            "claim:672", "progress:running:2",
            "claim:673", "queue:673", "progress:running:3",
            "progress:complete:3",
        ],
    }


def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
  capacity:()=>1,
  claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
  queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
}});
flow.run([{{repository:'stackchain/dashboard',number:671}}])
  .then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""

    assert run_node(script) == {
        "result": {
            "status": "complete",
            "selected": 1,
            "available": 1,
            "queued": [],
            "failed": [{
                "key": "stackchain/dashboard#671",
                "reason": "assigned but Today sync is unavailable",
                "assigned": True,
            }],
        },
        "calls": ["claim", "queue"],
    }


def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const values={{}};
const storage={{
  getItem:key=>values[key] || null,
  setItem:(key,value)=>{{values[key]=value;}},
  removeItem:key=>{{delete values[key];}},
}};
const calls=[];
const item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
const first=createBatchFindWork({{
  capacity:()=>1, storage, owner:()=> 'timmy',
  claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
  queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
}});
first.run([item]).then(firstResult=>{{
  const second=createBatchFindWork({{
    capacity:()=>1, storage, owner:()=> 'timmy',
    claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
    queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
  }});
  return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
    firstResult, resumed, calls, keys:Object.keys(values),
  }})));
}});
"""

    assert run_node(script) == {
        "firstResult": {
            "status": "complete", "selected": 1, "available": 1, "queued": [],
            "failed": [{
                "key": "stackchain/dashboard#679",
                "reason": "assigned but Today sync is unavailable",
                "assigned": True,
            }],
        },
        "resumed": {
            "status": "complete", "selected": 1, "available": 1,
            "queued": ["stackchain/dashboard#679"], "failed": [],
        },
        "calls": ["claim", "queue-failed", "queue-resumed:timmy"],
        "keys": [],
    }


def test_interrupted_batch_restores_operation_context_for_idempotent_recovery():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const values={{}};
const storage={{
  getItem:key=>values[key] || null,
  setItem:(key,value)=>{{values[key]=value;}},
  removeItem:key=>{{delete values[key];}},
}};
const calls=[];
const options={{
  capacity:()=>2, storage, owner:()=> 'timmy', journalName:'search-later-batch',
  queueFailureReason:'assigned but Later could not be saved',
  claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
}};
const first=createBatchFindWork({{...options, queue:(item,context)=>{{
  calls.push(['first',item.number,context?.until]);
  return item.number===813 ? 'queued' : 'unavailable';
}}}});
const items=[813,814].map(number=>({{repository:'stackchain/dashboard',number}}));
first.run(items, {{}}, {{until:'2026-08-15T09:00:00.000Z'}}).then(firstResult=>{{
  const second=createBatchFindWork({{...options, queue:(item,context)=>{{
    calls.push(['resume',item.number,context?.until]);
    return 'queued';
  }}}});
  return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
    firstResult,resumed,calls,keys:Object.keys(values),
  }})));
}});
"""

    result = run_node(script)
    assert result["firstResult"]["queued"] == ["stackchain/dashboard#813"]
    assert result["firstResult"]["failed"] == [{
        "key": "stackchain/dashboard#814",
        "reason": "assigned but Later could not be saved",
        "assigned": True,
    }]
    assert result["resumed"]["queued"] == [
        "stackchain/dashboard#813", "stackchain/dashboard#814",
    ]
    assert result["calls"] == [
        ["first", 813, "2026-08-15T09:00:00.000Z"],
        ["first", 814, "2026-08-15T09:00:00.000Z"],
        ["resume", 814, "2026-08-15T09:00:00.000Z"],
    ]
    assert result["keys"] == []


def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const key='stackchain.find-work-batch.v1.timmy';
const values={{[key]:JSON.stringify({{
  owner:'timmy', available:2, items:[
    {{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
    {{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
  ],
}})}};
const calls=[];
const flow=createBatchFindWork({{
  capacity:()=>2, owner:()=> 'timmy',
  storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
  claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
  queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
}});
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""

    assert run_node(script) == {
        "result": {
            "status": "complete", "selected": 2, "available": 2,
            "queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
            "failed": [],
        },
        "calls": ["claim:679", "queue:679"],
    }


def test_search_batch_uses_an_isolated_owner_journal_and_can_resume_without_dom_mounting():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const values={{}};
const calls=[];
const storage={{
  getItem:key=>values[key] || null,
  setItem:(key,value)=>{{values[key]=value;}},
  removeItem:key=>{{delete values[key];}},
}};
const flow=createBatchFindWork({{
  capacity:()=>1, owner:()=> 'timmy', storage,
  journalName:'search-today-batch', autoMount:false,
  claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
  queue:()=>{{calls.push('queue');return 'sync-unavailable';}},
}});
flow.run([{{repository:'stackchain/dashboard',number:809}}]).then(result=>
  process.stdout.write(JSON.stringify({{result,keys:Object.keys(values),calls,pending:flow.pending()}})));
"""

    assert run_node(script) == {
        "result": {
            "status": "complete", "selected": 1, "available": 1, "queued": [],
            "failed": [{
                "key": "stackchain/dashboard#809",
                "reason": "assigned but Today sync is unavailable",
                "assigned": True,
            }],
        },
        "keys": ["stackchain.search-today-batch.v1.timmy"],
        "calls": ["queue"],
        "pending": 1,
    }


def test_time_budget_blocks_claims_until_every_estimate_fits():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
  capacity:()=>3,
  timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
  claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
  queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
}});
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
Promise.all([
  flow.run(items, {{'stackchain/dashboard#701':30}}),
  flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
"""
    assert run_node(script) == {
        "results": [
            {
                "status": "estimates-required", "selected": 2, "available": 3,
                "queued": [], "failed": [], "remaining_minutes": 60,
                "requested_minutes": 30,
                "invalid": ["stackchain/dashboard#702"],
            },
            {
                "status": "over-budget", "selected": 2, "available": 3,
                "queued": [], "failed": [], "remaining_minutes": 60,
                "requested_minutes": 75, "over_minutes": 15,
            },
        ],
        "calls": [],
    }


def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
    script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
  capacity:()=>3,
  timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
  claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
  queue:item=>'queued',
  persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
}});
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
  'stackchain/dashboard#701':30,
  'stackchain/dashboard#702':45,
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
    assert run_node(script) == {
        "result": {
            "status": "complete", "selected": 2, "available": 3,
            "queued": ["stackchain/dashboard#701"],
            "failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
        },
        "calls": ["701:30"],
    }


def test_mobile_find_work_has_preclaim_time_review_controls():
    html = HTML.read_text()
    dashboard = DASHBOARD.read_text()
    css = CSS.read_text()

    assert 'id="find-work-estimate-review"' in html
    assert 'id="find-work-estimate-list"' in html
    assert 'id="confirm-find-work-estimates"' in html
    assert 'inputmode="numeric"' in dashboard
    assert "timeBudget:" in dashboard
    assert "persistEstimate:" in dashboard
    assert ".find-work-estimate-review" in css
    assert ".find-work-estimate-review input" in css and "min-height:44px" in css


def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
    script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const pages={{
  1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
  2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
}};
const controller=createFindWork({{
  fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
  Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
  onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
controller.load().then(()=>{{
  controller.startSelection();
  controller.toggleSelection(controller.items()[0]);
  return controller.loadMore();
}}).then(()=>{{
  controller.toggleSelection(controller.items()[1]);
  return controller.claim(controller.items()[0]);
}}).then(()=>process.stdout.write(JSON.stringify({{
  selected:controller.selectedItems().map(item=>item.number),
  selecting:controller.selection().active,
}})));
"""

    assert run_node(script) == {"selected": [672], "selecting": True}


def test_fill_today_selects_ranked_matches_up_to_remaining_capacity():
    script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const states=[];
const controller=createFindWork({{
  fetchJson:()=>Promise.resolve({{items:[
    {{id:1,repository:'stackchain/dashboard',number:701}},
    {{id:2,repository:'stackchain/dashboard',number:702}},
    {{id:3,repository:'stackchain/dashboard',number:703}},
    {{id:4,repository:'stackchain/dashboard',number:704}},
  ],page:1,total:4,has_more:false}}),
  onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
  onSelection:state=>states.push(state),
}});
controller.load().then(()=>{{
  controller.startSelection();
  controller.toggleSelection(controller.items()[1]);
  const outcome=controller.fillSelection(3);
  process.stdout.write(JSON.stringify({{
    outcome,
    selected:controller.selectedItems().map(item=>item.number),
    lastState:states.at(-1),
  }}));
}});
"""

    assert run_node(script) == {
        "outcome": {"selected": 3, "added": 2, "limit": 3},
        "selected": [702, 701, 703],
        "lastState": {
            "active": True,
            "count": 3,
            "ids": [
                "stackchain/dashboard#702",
                "stackchain/dashboard#701",
                "stackchain/dashboard#703",
            ],
        },
    }


def test_fill_today_with_no_capacity_does_not_change_selection():
    script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const controller=createFindWork({{
  fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
  onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
controller.load().then(()=>{{
  const outcome=controller.fillSelection(0);
  process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
}});
"""

    assert run_node(script) == {
        "outcome": {"selected": 0, "added": 0, "limit": 0},
        "selection": {"active": False, "count": 0, "ids": []},
    }


def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
    html = HTML.read_text()
    dashboard = DASHBOARD.read_text()
    css = CSS.read_text()
    worker = WORKER.read_text()
    navigation = FIND_WORK_NAV.read_text()

    assert 'id="select-find-work"' in html
    assert 'id="batch-find-work-actions"' in html
    assert 'id="claim-selected-work"' in html
    assert 'aria-live="polite"' in html
    assert "createBatchFindWork({" in dashboard
    assert "options.controller?.startSelection?.()" in navigation
    assert "options.controller?.toggleSelection" in navigation
    assert ".find-work-batch-actions" in css
    assert "position:sticky" in css
    assert "env(safe-area-inset-bottom)" in css
    assert "BASE + 'static/batch-find-work.js'" in worker
    assert '<script src="static/batch-find-work.js"></script>' in html


def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
    html = HTML.read_text()
    dashboard = DASHBOARD.read_text()
    css = CSS.read_text()

    assert 'id="resume-find-work-batch"' in html
    assert 'aria-describedby="find-work-status"' in html
    assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
    assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
    assert ".find-work-batch-recovery" in css and "min-height:44px" in css


def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
    html = HTML.read_text()
    dashboard = DASHBOARD.read_text()
    css = CSS.read_text()

    assert 'id="fill-find-work-today"' in html
    assert 'aria-describedby="find-work-selection-status"' in html
    assert "findWorkController.fillSelection(remainingSlots)" in dashboard
    assert "openFit:openFindWorkEstimateReview" in dashboard
    assert ".fill-find-work-today" in css and "min-height:44px" in css


def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
    script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const pending=[];
const rendered=[];
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
const controller=createFindWork({{
  fetchJson,
  onItems:items=>rendered.push(items.map(item=>item.number)),
  onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
const first=controller.search('api');
const second=controller.search('dashboard');
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
second.then(()=>{{
  controller.startSelection();
  controller.toggleSelection(controller.items()[0]);
  const third=controller.search('worker');
  pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
  return third;
}}).then(()=>{{
  controller.toggleSelection(controller.items()[0]);
  pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
  return first;
}}).then(()=>process.stdout.write(JSON.stringify({{
  calls:pending.map(entry=>entry.path),
  rendered,
  items:controller.items().map(item=>item.number),
  selected:controller.selectedItems().map(item=>item.number),
}})));
"""

    assert run_node(script) == {
        "calls": [
            "api/v1/available-issues?page=1&facets=true&q=api",
            "api/v1/available-issues?page=1&facets=true&q=dashboard",
            "api/v1/available-issues?page=1&facets=true&q=worker",
        ],
        "rendered": [[673], [674]],
        "items": [674],
        "selected": [673, 674],
    }


def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
    html = HTML.read_text()
    dashboard = DASHBOARD.read_text()
    css = CSS.read_text()

    assert 'id="find-work-search"' in html
    assert 'id="clear-find-work-search"' in html
    assert 'id="find-work-match-status"' in html
    assert 'aria-live="polite"' in html
    assert "findWorkController.search(" in dashboard
    assert "#find-work-search" in dashboard
    assert ".find-work-search" in css
    assert ".find-work-search button" in css and "min-height:44px" in css


def test_find_work_facets_serialize_filters_and_preserve_selection():
    script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const calls=[];
const controller=createFindWork({{
  fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
  onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
}});
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
  calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
}})));
"""

    assert run_node(script) == {
        "calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
        "filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
        "selected": [9],
    }


def test_mobile_find_work_exposes_accessible_touch_sized_facets():
    html = HTML.read_text()
    dashboard = DASHBOARD.read_text()
    css = CSS.read_text()

    assert 'id="find-work-filters"' in html
    assert 'id="find-work-repository-filters"' in html
    assert 'id="find-work-label-filters"' in html
    assert 'id="clear-find-work-filters"' in html
    assert 'aria-label="Filter available work"' in html
    assert "setFilters(next)" in PICK_WORK.read_text()
    assert ".find-work-filters" in css
    assert ".find-work-filter-option" in css and "min-height:44px" in css
