import json
import subprocess
from pathlib import Path


FRONTEND = Path(__file__).parents[1] / "frontend"
CONTROLLER = FRONTEND / "tomorrow-plan.js"
INDEX = FRONTEND / "index.html"
CSS = FRONTEND / "dashboard.css"


def run_controller(scenario: str) -> dict:
    harness = f"""
const createTomorrowPlan = require({json.dumps(str(CONTROLLER))});
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    completed = subprocess.run(
        ["node", "-e", harness], check=True, capture_output=True, text=True
    )
    return json.loads(completed.stdout)


def test_tomorrow_planner_restores_an_account_bound_plan_before_network_delivery():
    result = run_controller("""
const values=new Map();
const storage={
  getItem:key=>values.has(key)?values.get(key):null,
  setItem:(key,value)=>values.set(key,value),
  removeItem:key=>values.delete(key),
};
let login='Timmy';
let requests=0;
const options={storage,getLogin:()=>login,fetchJson:async()=>{requests+=1;throw new Error('offline');},
  localDate:()=> '2026-08-19',timeZone:()=> 'America/New_York'};
const first=createTomorrowPlan(options);
first.adopt({revision:7,ids:[],capacity_minutes:null,estimates:{}});
const queued=first.stage({ids:['issue:r:3:','issue:r:2:'],capacity_minutes:120,
  estimates:{'issue:r:3:':45,'issue:r:2:':30}});
const restored=await createTomorrowPlan(options).load();
login='alexander';
let otherError='';
try { await createTomorrowPlan(options).load(); } catch(error) { otherError=error.message; }
console.log(JSON.stringify({queued,restored,requests,otherError,keys:[...values.keys()]}));
""")

    assert result["queued"]["sync_pending"] is True
    assert result["restored"] == result["queued"]
    assert result["requests"] == 1
    assert result["otherError"] == "offline"
    assert result["keys"] == ["stackchain.tomorrow-sync.v1.timmy"]


def test_tomorrow_planner_flushes_once_and_removes_pending_only_after_server_receipt():
    result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let resolveRequest;
let requests=0;
const fetchJson=async (_url,options={})=>{
  requests+=1;
  const body=JSON.parse(options.body);
  await new Promise(resolve=>{resolveRequest=()=>resolve({revision:9,...body});});
  return {revision:9,...body};
};
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
  localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
planner.adopt({revision:8,ids:[],capacity_minutes:null,estimates:{}});
planner.stage({ids:['issue:r:4:'],capacity_minutes:60,estimates:{'issue:r:4:':30}});
const first=planner.flush();
const second=planner.flush();
await Promise.resolve();
const pendingDuring=planner.pending();
resolveRequest();
const [saved,reused]=await Promise.all([first,second]);
console.log(JSON.stringify({requests,pendingDuring,saved,reused,pendingAfter:planner.pending(),keys:[...values.keys()]}));
""")

    assert result["requests"] == 1
    assert result["pendingDuring"]["ids"] == ["issue:r:4:"]
    assert result["saved"]["revision"] == 9
    assert result["reused"]["revision"] == 9
    assert result["pendingAfter"] is False
    assert result["keys"] == []


def test_tomorrow_planner_preserves_local_and_server_plans_on_revision_conflict():
    result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const requests=[];
const fetchJson=async (_url,options={})=>{
  requests.push(options.method||'GET');
  if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
  return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'};
};
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
  localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{}});
let message='';
try { await planner.flush(); } catch(error) { message=error.message; }
console.log(JSON.stringify({requests,message,pending:planner.pending(),conflict:planner.conflict()}));
""")

    assert result["requests"] == ["PUT", "GET"]
    assert result["message"] == "changed elsewhere"
    assert result["pending"]["ids"] == ["issue:r:phone:"]
    assert result["conflict"]["local"]["ids"] == ["issue:r:phone:"]
    assert result["conflict"]["remote"]["ids"] == ["issue:r:server:"]
    assert result["conflict"]["remote"]["revision"] == 12


def test_tomorrow_planner_keeps_phone_plan_by_rebasing_once_onto_conflict_revision():
    result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const requests=[];
let puts=0;
const fetchJson=async (_url,options={})=>{
  requests.push({method:options.method||'GET',body:options.body?JSON.parse(options.body):null});
  if(options.method==='PUT'&&puts++===0){const error=new Error('changed elsewhere');error.status=409;throw error;}
  if(options.method==='PUT') return {revision:13,...JSON.parse(options.body)};
  return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{'issue:r:server:':30},plan_date:'2026-08-20',timezone:'UTC'};
};
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
  localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{'issue:r:phone:':45}});
try { await planner.flush(); } catch(_error) {}
const saved=await planner.keepLocal();
console.log(JSON.stringify({saved,requests,pending:planner.pending(),conflict:planner.conflict(),state:planner.state()}));
""")

    assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"]
    assert result["requests"][2]["body"]["base_revision"] == 12
    assert result["requests"][2]["body"]["ids"] == ["issue:r:phone:"]
    assert result["saved"]["revision"] == 13
    assert result["pending"] is False
    assert result["conflict"] is None
    assert result["state"]["ids"] == ["issue:r:phone:"]


def test_tomorrow_planner_uses_server_plan_without_a_second_write():
    result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const requests=[];
const fetchJson=async (_url,options={})=>{
  requests.push(options.method||'GET');
  if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
  return {revision:12,ids:['issue:r:server:'],capacity_minutes:60,estimates:{'issue:r:server:':30},plan_date:'2026-08-20',timezone:'UTC'};
};
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson,
  localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
planner.stage({ids:['issue:r:phone:'],capacity_minutes:90,estimates:{'issue:r:phone:':45}});
try { await planner.flush(); } catch(_error) {}
const adopted=planner.useRemote();
console.log(JSON.stringify({adopted,requests,pending:planner.pending(),conflict:planner.conflict(),state:planner.state()}));
""")

    assert result["requests"] == ["PUT", "GET"]
    assert result["adopted"]["revision"] == 12
    assert result["adopted"]["ids"] == ["issue:r:server:"]
    assert result["pending"] is False
    assert result["conflict"] is None
    assert result["state"] == result["adopted"]


def test_tomorrow_conflict_cannot_be_read_or_resolved_after_account_switch():
    result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
let login='timmy';
const fetchJson=async (_url,options={})=>{
  if(options.method==='PUT'){const error=new Error('changed elsewhere');error.status=409;throw error;}
  return {revision:12,ids:['issue:private:timmy:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'};
};
const planner=createTomorrowPlan({storage,getLogin:()=>login,fetchJson,
  localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
planner.adopt({revision:11,ids:[],capacity_minutes:null,estimates:{}});
planner.stage({ids:['issue:private:phone:'],capacity_minutes:90,estimates:{}});
try { await planner.flush(); } catch(_error) {}
const before=planner.conflict();
login='alexander';
const after=planner.conflict();
const keep=await planner.keepLocal();
const use=planner.useRemote();
login='timmy';
const restored=planner.conflict();
console.log(JSON.stringify({before,after,keep,use,restored}));
""")

    assert result["before"]["remote"]["ids"] == ["issue:private:timmy:"]
    assert result["after"] is None
    assert result["keep"] is False
    assert result["use"] is False
    assert result["restored"]["local"]["ids"] == ["issue:private:phone:"]


def test_tomorrow_planner_loads_and_saves_independently_from_today():
    result = run_controller("""
const requests=[];
const fetchJson=async (url, options={})=>{
  requests.push({url,method:options.method||'GET',body:options.body ? JSON.parse(options.body) : null});
  if ((options.method||'GET') === 'GET') return {
    revision:2, ids:['issue:r:2:'], capacity_minutes:90,
    estimates:{'issue:r:2:':45}, plan_date:'2026-08-20', timezone:'UTC'
  };
  return {revision:3,...JSON.parse(options.body)};
};
const planner=createTomorrowPlan({fetchJson, localDate:()=> '2026-08-19', timeZone:()=> 'UTC'});
const loaded=await planner.load();
const saved=await planner.save({ids:['issue:r:3:'],capacity_minutes:120,estimates:{'issue:r:3:':60}});
console.log(JSON.stringify({loaded,saved,requests}));
""")

    assert result["loaded"]["ids"] == ["issue:r:2:"]
    assert result["saved"]["plan_date"] == "2026-08-20"
    assert result["requests"] == [
        {"url": "api/v1/tomorrow", "method": "GET", "body": None},
        {
            "url": "api/v1/tomorrow",
            "method": "PUT",
            "body": {
                "base_revision": 2,
                "ids": ["issue:r:3:"],
                "capacity_minutes": 120,
                "estimates": {"issue:r:3:": 60},
                "plan_date": "2026-08-20",
                "timezone": "UTC",
            },
        },
    ]
    assert all(request["url"] != "api/v1/today" for request in result["requests"])


def test_pending_tomorrow_plan_is_not_promoted_before_account_sync():
    result = run_controller("""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const requests=[];
let date='2026-08-19';
const planner=createTomorrowPlan({storage,getLogin:()=> 'timmy',fetchJson:async(url)=>{requests.push(url);return {};},
  localDate:()=> date,timeZone:()=> 'UTC'});
planner.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
planner.stage({ids:['issue:r:2:'],capacity_minutes:60,estimates:{}});
date='2026-08-22';
const promoted=await planner.promote(7);
console.log(JSON.stringify({promoted,requests,pending:planner.pending()}));
""")

    assert result["promoted"] is False
    assert result["requests"] == []
    assert result["pending"]["ids"] == ["issue:r:2:"]


def test_tomorrow_planner_promotes_overdue_plans_with_a_stable_retry_identity():
    result = run_controller("""
const requests=[];
const fetchJson=async (url, options={})=>{
  requests.push({url,body:options.body ? JSON.parse(options.body) : null});
  return {ids:['issue:r:2:'],revision:8};
};
const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
before.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const early=await before.promote(7);
const overdue=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-22',timeZone:()=> 'UTC'});
overdue.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const promoted=await overdue.promote(7);
const retried=await overdue.promote(7);
console.log(JSON.stringify({early,promoted,retried,requests}));
""")

    assert result["early"] is False
    assert result["promoted"]["revision"] == 8
    assert result["retried"]["revision"] == 8
    assert result["requests"] == [
        {
            "url": "api/v1/tomorrow/promote",
            "body": {
                "promotion_id": "tomorrow-2026-08-20-r3",
                "tomorrow_revision": 3,
                "today_revision": 7,
            },
        },
        {
            "url": "api/v1/tomorrow/promote",
            "body": {
                "promotion_id": "tomorrow-2026-08-20-r3",
                "tomorrow_revision": 3,
                "today_revision": 7,
            },
        },
    ]


def test_tomorrow_rollover_checks_startup_reconnect_foreground_and_next_midnight_single_flight():
    result = run_controller("""
const listeners={};
const timers=[];
const cleared=[];
let checks=0;
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
const windowObject={addEventListener:(name,fn)=>listeners['window:'+name]=fn};
const documentObject={hidden:false,addEventListener:(name,fn)=>listeners['document:'+name]=fn};
const lifecycle=planner.startLifecycle({
  windowObject,documentObject,check:async()=>{checks+=1;await Promise.resolve();return true;},
  setTimer:(fn,delay)=>{timers.push({fn,delay});return timers.length;},
  clearTimer:id=>cleared.push(id),nextDelay:()=>1234,
});
await lifecycle.run();
const reconnect=listeners['window:online']();
const foreground=listeners['document:visibilitychange']();
await Promise.all([reconnect,foreground]);
await timers[timers.length-1].fn();
console.log(JSON.stringify({checks,delays:timers.map(timer=>timer.delay),cleared}));
""")

    assert result["checks"] == 3
    assert result["delays"] == [1234, 1234, 1234]
    assert result["cleared"] == [1, 2]


def test_tomorrow_rollover_falls_back_when_the_reported_timezone_is_invalid():
    result = run_controller("""
let scheduled=null;
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'Etc/Unknown'});
const lifecycle=planner.startLifecycle({
  check:async()=>false,setTimer:(_fn,delay)=>{scheduled=delay;return 1;},clearTimer:()=>{},
});
await lifecycle.run();
console.log(JSON.stringify({scheduled:Number.isFinite(scheduled)&&scheduled>0}));
""")

    assert result == {"scheduled": True}


def test_tomorrow_planner_summarizes_empty_and_capacity_aware_plans_for_mobile_queues():
    result = run_controller("""
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
const empty=planner.summary({ids:[],capacity_minutes:null,estimates:{}});
const planned=planner.summary({
  ids:['issue:r:1:','issue:r:2:','issue:r:3:'], capacity_minutes:120,
  estimates:{'issue:r:1:':30,'issue:r:2:':45,'issue:r:3:':30}
});
const unestimated=planner.summary({ids:['issue:r:4:'],capacity_minutes:null,estimates:{}});
const pending=planner.summary({ids:['issue:r:5:'],capacity_minutes:60,estimates:{'issue:r:5:':30},sync_pending:true});
console.log(JSON.stringify({empty,planned,unestimated,pending}));
""")

    assert result == {
        "empty": "Nothing planned",
        "planned": "3 planned · 105 of 120 min",
        "unestimated": "1 planned",
        "pending": "1 planned · 30 of 60 min · sync pending",
    }


def test_mobile_tomorrow_conflict_review_exposes_two_touch_safe_resolution_paths():
    index = INDEX.read_text()
    css = CSS.read_text()
    dashboard = (FRONTEND / "dashboard.js").read_text()

    assert 'id="tomorrow-conflict-review"' in index
    assert 'id="tomorrow-conflict-phone-plan"' in index
    assert 'id="tomorrow-conflict-server-plan"' in index
    assert 'id="keep-phone-tomorrow"' in index
    assert 'id="use-server-tomorrow"' in index
    assert "Conflict · review required" in dashboard
    assert "if (tomorrowPlan.conflict())" in dashboard
    assert "tomorrowPlan.keepLocal()" in dashboard
    assert "tomorrowPlan.useRemote()" in dashboard
    assert ".tomorrow-conflict-actions button { min-height:44px;" in css


def test_mobile_tomorrow_save_is_admitted_before_background_delivery():
    dashboard = (FRONTEND / "dashboard.js").read_text()

    assert "storage:localStorage" in dashboard
    assert "getLogin:() => planningOwnerLogin" in dashboard
    assert "const staged = tomorrowPlan.stage(normalized);" in dashboard
    assert "if (!staged) {" in dashboard
    assert "Free browser storage and retry." in dashboard
    assert "renderTomorrowQueueSummary(staged);" in dashboard
    assert "Tomorrow saved on this phone · sync pending." in dashboard
    assert "tomorrowPlan.flush().then" in dashboard
    assert "syncPendingTomorrow()" in dashboard
    assert dashboard.count("syncPendingTomorrow();") >= 2
    assert "Another device changed Tomorrow" in dashboard


def test_mobile_queues_expose_tomorrow_and_keep_duplicate_header_actions_hidden():
    index = INDEX.read_text()
    css = CSS.read_text()
    dashboard = (FRONTEND / "dashboard.js").read_text()

    assert 'data-mobile-queue="tomorrow"' in index
    assert 'id="mobile-tomorrow-summary"' in index
    assert '>Nothing planned<' in index
    assert "name === 'tomorrow' ? openTomorrowPlanner" in dashboard
    assert "tomorrowPlan.summary()" in dashboard
    assert "renderTomorrowQueueSummary(saved)" in dashboard
    assert "planTodayTrigger?.dataset.mobileQueue === 'tomorrow'" in dashboard
    assert "qs('#mobile-queue-sheet').showModal()" in dashboard
    assert ".my-work-actions { display:none; }\n  .my-work-actions { display:flex; }" not in css
    assert ".my-work-actions { display:none; }" in css


def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner():
    index = INDEX.read_text()
    css = CSS.read_text()
    dashboard = (FRONTEND / "dashboard.js").read_text()

    assert '<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>' in index
    assert ".plan-tomorrow { min-height:44px;" in css
    assert '<script src="static/tomorrow-plan.js"></script>' in index
    assert "const tomorrowPlan = createTomorrowPlan" in dashboard
    assert "qs('#plan-tomorrow').addEventListener('click'" in dashboard
    assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
    assert "tomorrowPlan.load()" in dashboard
    assert "weekWorkflow.promote(plan)" in dashboard
    assert "tomorrowPlan.startLifecycle" in dashboard
