import json
import subprocess
from pathlib import Path

import pytest

from tests.dashboard_bundle import dashboard


TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
TODAY_COMPLETION = Path(__file__).parents[1] / "frontend" / "today-completion.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"


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


@pytest.mark.anyio
async def test_dashboard_surfaces_automatic_planning_retry_guidance():
    html = await dashboard()

    assert "error.retryAfter = retryAfter === null ? undefined : Number(retryAfter)" in html
    assert "Today saved on this device · retrying" in html
    assert "Later saved on this device · retrying" in html


def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
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';
const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}});
const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const first = queue.add(issue(1));
const duplicate = queue.add(issue(1));
queue.add(issue(2));
queue.add(issue(3));
const full = queue.add(issue(4));
queue.move(issue(3), 'up');
queue.move(issue(3), 'up');
const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number);
const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}})
  .reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
login = 'alexander';
const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}}));
"""

    assert json.loads(run_node(script)) == {
        "first": "added",
        "duplicate": "exists",
        "full": "full",
        "timmy": [3, 1, 2],
        "persisted": [3, 1, 2],
        "isolated": [],
    }


def test_today_capacity_metadata_is_account_scoped_and_prunes_removed_estimates():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let login = 'timmy';
const queue = createTodayWork({{storage, getLogin: () => login}});
queue.replace(['issue:r:1:', 'issue:r:2:']);
const saved = queue.replacePlanning({{capacity_minutes:120,estimates:{{'issue:r:1:':60,'issue:r:2:':45}}}});
queue.replace(['issue:r:2:']);
const timmy = queue.planning();
login = 'alexander';
const alexander = queue.planning();
process.stdout.write(JSON.stringify({{saved,timmy,alexander}}));
"""
    assert json.loads(run_node(script)) == {
        "saved": True,
        "timmy": {"capacity_minutes": 120, "estimates": {"issue:r:2:": 45}},
        "alexander": {"capacity_minutes": None, "estimates": {}},
    }


def test_today_estimated_runway_follows_current_session_position():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue',repository:'r',number}});
queue.replace(['issue:r:1:','issue:r:2:','issue:r:3:']);
queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}});
process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}}));
"""
    assert json.loads(run_node(script)) == {
        "first": {
            "current_minutes": 30, "remaining_minutes": 135,
            "future_minutes": 105, "capacity_minutes": 180,
        },
        "second": {
            "current_minutes": 60, "remaining_minutes": 105,
            "future_minutes": 45, "capacity_minutes": 180,
        },
    }


def test_today_live_runway_uses_elapsed_work_and_reports_capacity_risk():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue',repository:'r',number}});
queue.replace(['issue:r:1:','issue:r:2:']);
queue.replacePlanning({{capacity_minutes:100,estimates:{{'issue:r:1:':30,'issue:r:2:':60}}}});
const healthy = queue.runway([item(1),item(2)],0,{{current_elapsed_ms:10*60000,total_elapsed_ms:10*60000}});
const over = queue.runway([item(1),item(2)],0,{{current_elapsed_ms:45*60000,total_elapsed_ms:45*60000}});
process.stdout.write(JSON.stringify({{healthy,over}}));
"""
    assert json.loads(run_node(script)) == {
        "healthy": {"current_minutes": 30, "remaining_minutes": 90, "future_minutes": 60, "capacity_minutes": 100},
        "over": {"current_minutes": 30, "remaining_minutes": 90, "future_minutes": 60, "capacity_minutes": 100},
    }


def test_today_live_runway_stays_truthful_when_estimates_or_capacity_are_missing():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue',repository:'r',number}});
queue.replace(['issue:r:1:','issue:r:2:']);
queue.replacePlanning({{capacity_minutes:null,estimates:{{'issue:r:1:':30}}}});
process.stdout.write(JSON.stringify(queue.runway([item(1),item(2)],0,{{current_elapsed_ms:40*60000,total_elapsed_ms:40*60000}})));
"""
    assert json.loads(run_node(script)) == {
        "current_minutes": 30,
        "remaining_minutes": None,
        "future_minutes": None,
        "capacity_minutes": None,
    }


def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
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 queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}});
queue.add(item(1)); queue.add(item(2)); queue.add(item(3));
process.stdout.write(JSON.stringify([
  queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9))
]));
"""

    assert json.loads(run_node(script)) == [
        {"can_up": False, "can_down": True},
        {"can_up": True, "can_down": True},
        {"can_up": True, "can_down": False},
        {"can_up": False, "can_down": False},
    ]


def test_done_for_today_removes_current_plan_item_syncs_and_advances_without_gitea_mutation():
    script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const calls = [];
const current = {{kind:'issue', repository:'stackchain/dashboard', number:421}};
const complete = createTodayCompletion({{
  todayWork: {{
    identity: item => `issue:${{item.repository}}:${{item.number}}:`,
    remove: item => {{ calls.push(`remove:${{item.number}}`); return true; }},
  }},
  todaySync: {{
    enqueue: (action, identity) => {{ calls.push(`sync:${{action}}:${{identity}}`); return true; }},
    flush: () => calls.push('flush'),
  }},
  refresh: () => calls.push('refresh'),
  warm: () => calls.push('warm'),
  workSession: {{ complete: () => calls.push('advance') }},
  announce: message => calls.push(`announce:${{message}}`),
}});
const completed = complete(current);
process.stdout.write(JSON.stringify({{completed, calls}}));
"""

    assert json.loads(run_node(script)) == {
        "completed": True,
        "calls": [
            "remove:421",
            "sync:remove:issue:stackchain/dashboard:421:",
            "flush",
            "refresh",
            "warm",
            "advance",
            "announce:Done for Today. The Gitea item is unchanged.",
        ],
    }


def test_done_for_today_can_restore_original_order_and_estimate_with_inverse_sync():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const todayWork = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue', repository:'r', number}});
todayWork.replace(['issue:r:1:', 'issue:r:2:', 'issue:r:3:']);
todayWork.replacePlanning({{capacity_minutes:180, estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}});
const calls = [];
const offers = [];
const complete = createTodayCompletion({{
  todayWork,
  todaySync: {{enqueue:(action,id,direction)=>{{calls.push([action,id,direction]);return true;}},flush:()=>calls.push(['flush'])}},
  workSession: {{complete:()=>calls.push(['advance'])}},
  refresh:()=>calls.push(['refresh']), warm:()=>calls.push(['warm']), announce:message=>calls.push(['announce',message]),
  now:()=>1000, onOffer:offer=>offers.push(offer),
}});
const completed = complete(item(2));
const restored = complete.undo();
process.stdout.write(JSON.stringify({{
  completed, restored, ids:todayWork.read(), planning:todayWork.planning(), calls,
  offer:offers[0] && {{identity:offers[0].identity, expires_at:offers[0].expires_at}},
}}));
"""

    assert json.loads(run_node(script)) == {
        "completed": True,
        "restored": "restored",
        "ids": ["issue:r:1:", "issue:r:2:", "issue:r:3:"],
        "planning": {
            "capacity_minutes": 180,
            "estimates": {
                "issue:r:1:": 30,
                "issue:r:2:": 60,
                "issue:r:3:": 45,
            },
        },
        "calls": [
            ["remove", "issue:r:2:", None], ["flush"], ["refresh"], ["warm"],
            ["advance"], ["announce", "Done for Today. The Gitea item is unchanged."],
            ["add", "issue:r:2:", None], ["move", "issue:r:2:", "up"],
            ["flush"], ["refresh"], ["warm"],
            ["announce", "Restored to Today. Your current work session is unchanged."],
        ],
        "offer": {"identity": "issue:r:2:", "expires_at": 11000},
    }


def test_today_completion_undo_expires_and_rejects_a_changed_plan_without_overwrite():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const todayWork = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue', repository:'r', number}});
let clock = 1000;
const messages = [];
const complete = createTodayCompletion({{
  todayWork, todaySync:{{enqueue:()=>true,flush:()=>undefined}}, workSession:{{complete:()=>undefined}},
  refresh:()=>undefined, warm:()=>undefined, announce:message=>messages.push(message), now:()=>clock,
}});
todayWork.replace(['issue:r:1:', 'issue:r:2:']);
complete(item(1));
todayWork.replace(['issue:r:2:', 'issue:r:3:']);
const changed = complete.undo();
todayWork.replace(['issue:r:1:', 'issue:r:2:']);
complete(item(1));
clock = 11000;
const expired = complete.undo();
process.stdout.write(JSON.stringify({{changed, expired, ids:todayWork.read(), messages}}));
"""

    assert json.loads(run_node(script)) == {
        "changed": "changed",
        "expired": "expired",
        "ids": ["issue:r:2:"],
        "messages": [
            "Done for Today. The Gitea item is unchanged.",
            "Today changed on this device. Add the item back from My Work.",
            "Done for Today. The Gitea item is unchanged.",
            "Undo expired. Add the item back to Today from My Work.",
        ],
    }


def test_done_for_today_does_not_advance_when_local_plan_persistence_fails():
    script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const calls = [];
const complete = createTodayCompletion({{
  todayWork: {{identity: () => 'issue:r:1:', remove: () => false}},
  todaySync: {{enqueue: () => calls.push('sync'), flush: () => calls.push('flush')}},
  refresh: () => calls.push('refresh'),
  warm: () => calls.push('warm'),
  workSession: {{complete: () => calls.push('advance')}},
  announce: message => calls.push(message),
}});
const completed = complete({{kind:'issue', repository:'r', number:1}});
process.stdout.write(JSON.stringify({{completed, calls}}));
"""

    assert json.loads(run_node(script)) == {
        "completed": False,
        "calls": ["Could not update Today on this device. Try again."],
    }


def test_done_for_today_restores_plan_and_keeps_session_when_durable_batch_admission_fails():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const todayWork = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = {{kind:'issue', repository:'r', number:1}};
todayWork.replace(['issue:r:1:', 'issue:r:2:']);
const calls = []; let offered = false;
globalThis.stackchainFirstTaskCompleting = () => true;
const complete = createTodayCompletion({{
  todayWork,
  todaySync: {{enqueueBatch: operations => {{calls.push(['batch', operations]); return false;}},
    flush: () => calls.push(['flush'])}},
  refresh: () => calls.push(['refresh']), warm: () => calls.push(['warm']),
  workSession: {{complete: () => calls.push(['advance'])}},
  announce: message => calls.push(['announce', message]), onOffer: () => {{offered = true;}},
}});
const completed = complete(item);
process.stdout.write(JSON.stringify({{completed, ids:todayWork.read(), calls, offered}}));
"""

    assert json.loads(run_node(script)) == {
        "completed": False,
        "ids": ["issue:r:1:", "issue:r:2:"],
        "calls": [
            ["batch", [
                {"action": "remove", "item_id": "issue:r:1:"},
                {"action": "activate", "item_id": "first-task", "activation_state": "complete"},
            ]],
            ["announce", "Device storage is full. Free space and try again."],
        ],
        "offered": False,
    }


def test_today_completion_reports_context_specific_success_and_partial_failure():
    script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const messages = [];
let removable = false;
const complete = createTodayCompletion({{
  todayWork: {{identity: () => 'review:r:7:', remove: () => removable}},
  todaySync: {{enqueue: () => true, flush: () => undefined}},
  refresh: () => undefined,
  warm: () => undefined,
  workSession: {{complete: () => undefined}},
  announce: message => messages.push(message),
}});
const options = {{
  successMessage: 'Review queued. Next Today item opened.',
  failureMessage: 'Review queued, but Today still needs completion.',
}};
const failed = complete({{kind:'review', repository:'r', number:7}}, options);
removable = true;
const completed = complete({{kind:'review', repository:'r', number:7}}, options);
process.stdout.write(JSON.stringify({{failed, completed, messages}}));
"""

    assert json.loads(run_node(script)) == {
        "failed": False,
        "completed": True,
        "messages": [
            "Review queued, but Today still needs completion.",
            "Review queued. Next Today item opened.",
        ],
    }


def test_done_for_today_logic_is_available_in_the_offline_app_shell():
    assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text()


def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
const item = number => ({{kind:'issue', repository:'r', number}});
queue.add(item(1));
queue.add(item(2));
const reports = [];
const visible = queue.reconcile([item(2)], {{
  pruneMissing: true,
  onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
}});
queue.reconcile([item(2)], {{
  pruneMissing: true,
  onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
}});
process.stdout.write(JSON.stringify({{reports, visible:visible.map(entry => entry.number), stored:queue.read()}}));
"""

    assert json.loads(run_node(script)) == {
        "reports": [{
            "retiredIds": ["issue:r:1:"],
            "idsDuringReport": ["issue:r:1:", "issue:r:2:"],
        }],
        "visible": [2],
        "stored": ["issue:r:2:"],
    }


def test_failed_retirement_queue_keeps_local_identity_for_retry():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
const item = number => ({{kind:'issue', repository:'r', number}});
queue.add(item(1));
const visible = queue.reconcile([], {{pruneMissing:true, onPrune:() => false}});
process.stdout.write(JSON.stringify({{visible, stored:queue.read()}}));
"""

    assert json.loads(run_node(script)) == {
        "visible": [],
        "stored": ["issue:r:1:"],
    }


def test_today_queue_adopts_bounded_server_order():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin: () => 'timmy', limit: 2}});
queue.add({{kind:'issue', repository:'r', number:1}});
const adopted = queue.replace(['issue:r:3:', 'issue:r:2:', 'issue:r:2:', '', 'issue:r:1:']);
process.stdout.write(JSON.stringify({{adopted, ids:queue.read()}}));
"""
    assert json.loads(run_node(script)) == {
        "adopted": True,
        "ids": ["issue:r:3:", "issue:r:2:"],
    }


@pytest.mark.anyio
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
    html = await dashboard()

    assert '<script src="static/today-work.js"></script>' in html
    assert '<script src="static/today-sync.js"></script>' in html
    assert 'data-work-filter="today"' in html
    assert 'data-work-count="today"' in html
    assert "const todayWork = createTodayWork({" in html
    assert "getTodayCount: () => todayMyWork.length" in html
    assert "startToday: startTodaySession" in html
    assert "selectedWorkFilter === 'today' ? todayMyWork :" in html
    assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork" in html
    assert 'data-today-add' in html
    assert 'data-today-remove' in html
    assert 'data-today-move="up"' in html
    assert 'data-today-move="down"' in html
    assert 'Today is limited to 5 items' in html
    assert '.today-actions button' in html and 'min-height:44px' in html
    assert 'id="today-sync-status"' in html
    assert "todaySync.enqueue('add'" in html
    assert "todaySync.enqueue('remove'" in html
    assert "todaySync.enqueue('move'" in html
    assert "const authoritativeMyWorkRefresh = liveMode && hasContextSnapshot &&" in html
    assert "!lastContextSnapshot?.error &&" in html
    assert "onPrune: retiredIds =>" in html
    assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html
    assert "if (queued) todaySync.flush();" in html
    assert "todaySync.flush();" in html
    assert "Another device filled Today · showing its saved plan." in html


@pytest.mark.anyio
async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_action():
    html = await dashboard()

    assert '<script src="static/today-completion.js"></script>' in html
    assert html.count('data-work-session-complete=') == 4
    assert html.count('Done for Today &amp; next') == 4
    assert "button.hidden = !workSession.checkpointed();" in html
    assert "const completeTodayItem = createTodayCompletion({" in html
    assert "refresh: () => refreshMyWorkView({ reconcileSession:false })," in html
    assert "document.querySelectorAll('[data-work-session-complete]')" in html
    assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
    assert ".work-session-nav button { min-height:44px; width:100%; }" in html
    assert "grid-template-columns:repeat(2,minmax(0,1fr))" in html


@pytest.mark.anyio
async def test_today_completion_surfaces_a_touch_safe_accessible_undo_receipt():
    html = await dashboard()

    assert 'id="today-completion-undo" role="status" aria-live="assertive" hidden' in html
    assert 'id="undo-today-completion"' in html
    assert 'Removed from Today. Undo?' in html
    assert "getElementById('today-completion-undo')" in TODAY_COMPLETION.read_text()
    assert ".today-completion-undo button { min-height:44px" in html
    assert "bottom:calc(224px + env(safe-area-inset-bottom))" in html


@pytest.mark.anyio
async def test_dashboard_activates_today_lifecycle_convergence():
    html = await dashboard()

    assert "todaySync.startLifecycle({ window, document });" in html


@pytest.mark.anyio
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
    html = await dashboard()

    assert "let planningOwnerLogin = '';" in html
    assert html.count("getLogin: () => planningOwnerLogin") == 4
    assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
    assert "planningOwnerLogin = retainedPlanningLogin;" in html
    assert "button.disabled = !planningOwnerLogin;" in html
    assert "activeFlushLogin = contextIdentityFresh ?" in html
    assert "getOwnerLogin: () => confirmedOwnerLogin" in html
    assert "Planning is unavailable until your operator identity is restored." in html
    assert "Could not save Today on this device." in html
    assert "Could not save Later on this device." in html
    assert "data-planning-disabled" in html
