import json
import subprocess
from pathlib import Path

from tests.dashboard_bundle import dashboard_bundle_text


OVERLAY_HISTORY = Path(__file__).parents[1] / "frontend" / "task-overlay-history.js"


def test_task_overlay_history_unwinds_preview_search_and_dashboard_without_duplicate_entries():
    script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const location = {{ hash:'', pathname:'/dashboard/' }};
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const changes = [];
const history = {{
  get state() {{ return stack[cursor]; }},
  pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
  back() {{ if (cursor > 0) cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
  history, location,
  eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
  onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
controller.open('search');
controller.open('search');
controller.open('search-preview');
controller.close();
controller.close();
process.stdout.write(JSON.stringify({{changes, stack, cursor, current:controller.current()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "changes": [
            ["search", None],
            ["search-preview", "search"],
            ["search", "search-preview"],
            [None, "search"],
        ],
        "stack": [
            {"page": "dashboard"},
            {"page": "dashboard", "taskOverlay": "search"},
            {"page": "dashboard", "taskOverlay": "search-preview"},
        ],
        "cursor": 0,
        "current": None,
    }


def test_task_overlay_history_rejects_unknown_states_and_preserves_existing_history_state():
    script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const pushed = [];
const history = {{
  state: {{ workRoute:'#/my-work/issue/stackchain/api/7' }},
  pushState(state) {{ this.state = state; pushed.push(state); }}, back() {{}},
}};
const controller = createTaskOverlayHistory({{
  history, location:{{}}, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
const invalid = controller.open('settings');
const valid = controller.open('new');
process.stdout.write(JSON.stringify({{invalid, valid, pushed}}));
"""
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "invalid": False,
        "valid": True,
        "pushed": [{
            "workRoute": "#/my-work/issue/stackchain/api/7",
            "taskOverlay": "new",
        }],
    }


def test_task_overlay_history_can_leave_an_overlay_for_a_work_route_transition():
    script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const changes = [];
const history = {{
  state: {{ page:'dashboard' }},
  pushState(state) {{ this.state = state; }},
  replaceState(state) {{ this.state = state; }},
  back() {{ throw new Error('route transitions must not race history.back'); }},
}};
const controller = createTaskOverlayHistory({{
  history, eventTarget:{{addEventListener() {{}}}},
  onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.open('find');
const left = controller.leave();
process.stdout.write(JSON.stringify({{left, state:history.state, current:controller.current(), changes}}));
"""
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "left": True,
        "state": {"page": "dashboard"},
        "current": None,
        "changes": [["find", None], [None, "find"]],
    }


def test_plan_today_uses_one_history_layer_and_can_leave_after_save():
    script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const changes = [];
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const history = {{
  get state() {{ return stack[cursor]; }},
  pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
  replaceState(state) {{ stack[cursor] = state; }},
  back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
  history,
  eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
  onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
const opened = controller.open('plan-today');
const repeated = controller.open('plan-today');
const left = controller.leave();
process.stdout.write(JSON.stringify({{
  opened, repeated, left, stack, cursor, current:controller.current(), changes,
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "opened": True,
        "repeated": True,
        "left": True,
        "stack": [
            {"page": "dashboard"},
            {"page": "dashboard"},
        ],
        "cursor": 1,
        "current": None,
        "changes": [["plan-today", None], [None, "plan-today"]],
    }


def test_plan_today_preview_is_a_nested_browser_history_layer():
    script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const changes = [];
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const history = {{
  get state() {{ return stack[cursor]; }},
  pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
  back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
  history,
  eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
  onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
controller.open('plan-today');
const previewed = controller.open('plan-today-preview');
controller.close();
process.stdout.write(JSON.stringify({{previewed, cursor, current:controller.current(), changes}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "previewed": True,
        "cursor": 1,
        "current": "plan-today",
        "changes": [
            ["plan-today", None],
            ["plan-today-preview", "plan-today"],
            ["plan-today", "plan-today-preview"],
        ],
    }


def test_dashboard_routes_mobile_task_overlays_through_browser_history():
    html = dashboard_bundle_text()

    assert '<script src="static/task-overlay-history.js"></script>' in html
    assert "createTaskOverlayHistory({" in html
    assert "taskOverlayHistory.open('new')" in html
    assert "taskOverlayHistory.open('find')" in html
    assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html
    assert "taskOverlayHistory.open('search-preview')" in html
    assert "taskOverlayHistory.close()" in html
    assert "saveIssueCaptureDraft();" in html
    assert "history.replaceState(history.state || {}, '', cleanUrl)" in html


def test_search_history_serializes_bounded_query_and_stable_preview_identity():
    script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const pushed = [];
const replaced = [];
const location = {{ pathname:'/dashboard/', search:'?keep=1', hash:'' }};
const history = {{
  state: {{ page:'dashboard' }},
  pushState(state, title, url) {{ this.state = state; pushed.push([state, url]); }},
  replaceState(state, title, url) {{ this.state = state; replaced.push([state, url]); }},
  back() {{}},
}};
const controller = createTaskOverlayHistory({{
  history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
controller.open('search', {{ query:'  release blocker  ' }});
controller.update({{ query:'release candidate' }});
controller.open('search-preview', {{
  query:'release candidate', preview:{{kind:'issue', repository:'stackchain/api', number:123,
  title:'must not be serialized', body:'secret'}},
}});
process.stdout.write(JSON.stringify({{pushed, replaced, current:controller.currentState()}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    payload = json.loads(result.stdout)
    assert len(payload["pushed"]) == 2
    assert len(payload["replaced"]) == 1
    assert payload["pushed"][0][1] == "/dashboard/?keep=1&search=release+blocker"
    assert payload["replaced"][0][1] == "/dashboard/?keep=1&search=release+candidate"
    assert payload["pushed"][1][1] == (
        "/dashboard/?keep=1&search=release+candidate&preview=issue%3Astackchain%2Fapi%3A123"
    )
    assert payload["current"] == {
        "kind": "search-preview",
        "query": "release candidate",
        "preview": {"kind": "issue", "repository": "stackchain/api", "number": 123},
    }


def test_search_history_restores_direct_url_and_rejects_malformed_or_oversized_state():
    script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{
  const changes = [];
  const location = {{ pathname:'/dashboard/', search, hash:'' }};
  const history = {{ state:null, replaceState(state) {{ this.state = state; }}, back() {{}} }};
  const controller = createTaskOverlayHistory({{
    history, location, eventTarget:{{addEventListener() {{}}}},
    onChange(kind, previous, detail) {{ changes.push([kind, previous, detail]); }},
  }});
  controller.start();
  return {{ state:controller.currentState(), changes }};
}}
process.stdout.write(JSON.stringify({{
  valid:restore('?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'),
  malformed:restore('?search=ok&preview=issue%3Ainvalid%3A0'),
  oversized:restore('?search=' + 'x'.repeat(201)),
}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    payload = json.loads(result.stdout)
    expected = {
        "kind": "search-preview",
        "query": "release blocker",
        "preview": {"kind": "pull", "repository": "stackchain/api", "number": 9},
    }
    assert payload["valid"]["state"] == expected
    assert payload["valid"]["changes"] == [["search-preview", None, expected]]
    assert payload["malformed"]["state"] == {"kind": "search", "query": "ok"}
    assert payload["oversized"]["state"] == {"kind": None}


def test_search_history_round_trips_bounded_type_status_and_repository_scope():
    script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{
  const location = {{pathname:'/dashboard/', search, hash:''}};
  const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}};
  const controller = createTaskOverlayHistory({{
    history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
  }});
  return {{state:controller.currentState(), url:history.url}};
}}
const location = {{pathname:'/dashboard/', search:'', hash:''}};
const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
  history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open',repository:'stackchain/api'}}}});
process.stdout.write(JSON.stringify({{
  opened:controller.currentState(), url:history.url,
  valid:restore('?search=mobile&search_kind=issue&search_state=closed&search_repository=stackchain%2Fapi'),
  invalid:restore('?search=mobile&search_kind=script&search_state=secret&search_repository=stackchain%2Fapi%2Fprivate'),
}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    payload = json.loads(result.stdout)
    assert payload["opened"] == {
        "kind": "search", "query": "mobile", "scope": {
            "kind": "pull", "state": "open", "repository": "stackchain/api"
        }
    }
    assert payload["url"] == (
        "/dashboard/?search=mobile&search_kind=pull&search_state=open&"
        "search_repository=stackchain%2Fapi"
    )
    assert payload["valid"]["state"]["scope"] == {
        "kind": "issue", "state": "closed", "repository": "stackchain/api"
    }
    assert payload["invalid"]["state"]["scope"] == {
        "kind": "all", "state": "all", "repository": ""
    }


def test_dashboard_persists_search_query_and_restores_canonical_preview():
    html = dashboard_bundle_text()

    assert "taskOverlayHistory.update({ query:e.target.value })" in html
    assert "scope:currentSearchScope(), preview:item.result" in html
    assert "detail?.query" in html
    assert "searchPreview.open(detail.preview).catch" in html
