import json
import subprocess
from pathlib import Path


POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"


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


def test_context_poller_is_single_flight_and_pauses_until_visible():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
let calls = 0;
let hidden = false;
let resolveRequest;
const timers = [];
const poller = createContextPoller({{
  fetchContext: () => {{
    calls += 1;
    if (calls > 1) return Promise.resolve({{ repos: [] }});
    return new Promise((resolve) => {{ resolveRequest = resolve; }});
  }},
  onSnapshot: () => {{}},
  onError: () => {{}},
  isHidden: () => hidden,
  setTimer: (callback) => {{ timers.push(callback); return timers.length; }},
  clearTimer: () => {{}},
  intervalMs: 8000,
}});

(async () => {{
  const first = poller.start();
  const joined = poller.refresh();
  const callsWhilePending = calls;
  resolveRequest({{ repos: [] }});
  await Promise.all([first, joined]);

  hidden = true;
  poller.setVisible(false);
  const scheduled = timers.shift();
  if (scheduled) scheduled();
  await Promise.resolve();
  const callsWhileHidden = calls;

  hidden = false;
  await poller.setVisible(true);
  process.stdout.write(JSON.stringify({{
    callsWhilePending,
    callsWhileHidden,
    callsAfterResume: calls,
  }}));
}})();
"""

    assert run_node(script) == {
        "callsWhilePending": 1,
        "callsWhileHidden": 1,
        "callsAfterResume": 2,
    }


def test_context_poller_sends_revisions_and_merges_changed_sections():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const requested = [];
const rendered = [];
const responses = [
  {{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [],
     sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
     revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }},
  {{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
     revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}],
     freshness: {{ age_seconds: 1 }} }},
  {{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
     revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }},
];
const poller = createContextPoller({{
  fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }},
  onSnapshot: (snapshot, changed) => rendered.push({{
    context: snapshot.context.user.login,
    event: snapshot.events[0].id,
    age: snapshot.freshness.age_seconds,
    changed,
  }}),
  onError: error => {{ throw error; }},
  setTimer: () => 1,
  clearTimer: () => {{}},
}});
(async () => {{
  await poller.refresh();
  await poller.refresh();
  await poller.refresh();
  process.stdout.write(JSON.stringify({{ requested, rendered }}));
}})();
"""

    assert run_node(script) == {
        "requested": [
            {},
            {"context": 1, "events": 1, "notifications": 1},
            {"context": 1, "events": 2, "notifications": 1},
        ],
        "rendered": [
            {"context": "timmy", "event": 1, "age": 0,
             "changed": ["context", "events", "notifications"]},
            {"context": "timmy", "event": 2, "age": 1,
             "changed": ["events"]},
            {"context": "timmy", "event": 2, "age": 2, "changed": []},
        ],
    }


def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
  fetchContext: () => Promise.resolve({{
    context: {{}}, events: [], notifications: [],
    freshness: {{
      fresh_for_seconds: 8,
      retry_in_seconds: 60,
      sections: {{
        context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
        events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
        notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
      }},
    }},
  }}),
  onSnapshot: () => {{}},
  onError: error => {{ throw error; }},
  setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
  clearTimer: () => {{}},
  intervalMs: 8000,
}});
(async () => {{
  await poller.start();
  process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""

    assert run_node(script) == {"delays": [60000]}


def test_context_poller_uses_earliest_healthy_section_freshness_deadline():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
  fetchContext: () => Promise.resolve({{
    context: {{}}, events: [], notifications: [],
    freshness: {{
      fresh_for_seconds: 8,
      retry_in_seconds: 60,
      sections: {{
        context: {{ degraded: false, age_seconds: 2 }},
        events: {{ degraded: false, age_seconds: 5 }},
        notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
      }},
    }},
  }}),
  onSnapshot: () => {{}},
  onError: error => {{ throw error; }},
  setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
  clearTimer: () => {{}},
  intervalMs: 8000,
}});
(async () => {{
  await poller.start();
  process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""

    assert run_node(script) == {"delays": [3000]}


def test_context_poller_does_not_spin_while_stale_sections_are_revalidating():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
  fetchContext: () => Promise.resolve({{
    context: {{}}, events: [], notifications: [],
    freshness: {{
      fresh_for_seconds: 8,
      sections: {{
        context: {{ degraded: false, revalidating: true, age_seconds: 12 }},
        events: {{ degraded: false, revalidating: true, age_seconds: 12 }},
        notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }},
      }},
    }},
  }}),
  onSnapshot: () => {{}},
  onError: error => {{ throw error; }},
  setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
  clearTimer: () => {{}},
  intervalMs: 8000,
}});
(async () => {{
  await poller.start();
  process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""

    assert run_node(script) == {"delays": [8000]}


def test_context_poller_honors_http_retry_after_delay():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const errors = [];
const unavailable = new Error('HTTP 503');
unavailable.retryAfterMs = 45000;
const poller = createContextPoller({{
  fetchContext: () => Promise.reject(unavailable),
  onSnapshot: () => {{}},
  onError: error => errors.push(error.message),
  setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
  clearTimer: () => {{}},
  intervalMs: 8000,
}});
(async () => {{
  await poller.start();
  process.stdout.write(JSON.stringify({{ delays, errors }}));
}})();
"""

    assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]}


def test_retry_after_parser_accepts_bounded_delta_seconds():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
process.stdout.write(JSON.stringify({{
  valid: createContextPoller.retryAfterMs('45'),
  zero: createContextPoller.retryAfterMs('0'),
  invalid: createContextPoller.retryAfterMs('later'),
  excessive: createContextPoller.retryAfterMs('999999'),
}}));
"""

    assert run_node(script) == {
        "valid": 45000,
        "zero": None,
        "invalid": None,
        "excessive": 300000,
    }


def test_context_poller_backs_off_transport_failures_and_resets_after_success():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
let calls = 0;
const poller = createContextPoller({{
  fetchContext: () => {{
    calls += 1;
    if (calls < 6) return Promise.reject(new TypeError('network unavailable'));
    return Promise.resolve({{ context: {{}}, events: [], notifications: [] }});
  }},
  onSnapshot: () => {{}},
  onError: () => {{}},
  setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
  clearTimer: () => {{}},
  intervalMs: 8000,
  maxBackoffMs: 60000,
}});
(async () => {{
  await poller.refresh();
  await poller.refresh();
  await poller.refresh();
  await poller.refresh();
  await poller.refresh();
  await poller.refresh();
  process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""

    assert run_node(script) == {
        "delays": [8000, 16000, 32000, 60000, 60000, 8000]
    }


def test_live_fetch_passes_retry_after_to_the_poller_error():
    source = DASHBOARD.read_text()

    assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source
    assert "error.retryAfterMs" in source


def test_live_revision_query_preserves_bounded_opaque_tokens():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const query = createContextPoller.buildRevisionQuery({{
  context: '0123456789abcdef.12',
  events: 'fedcba9876543210.3',
  notifications: 'not-a-token',
  extra: '0123456789abcdef.1',
}});
process.stdout.write(JSON.stringify({{ query }}));
"""

    assert run_node(script) == {
        "query": "context_revision=0123456789abcdef.12&events_revision=fedcba9876543210.3"
    }


def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const timers = [];
const deadlineTimers = [];
const errors = [];
const snapshots = [];
const signals = [];
let calls = 0;
const poller = createContextPoller({{
  fetchContext: (revisions, options) => {{
    calls += 1;
    signals.push(options.signal);
    if (calls === 1) return new Promise(() => {{}});
    return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }});
  }},
  onSnapshot: snapshot => snapshots.push(snapshot.context.user.login),
  onError: error => errors.push({{ name: error.name, message: error.message }}),
  setTimer: (callback, delay) => {{
    const timer = {{ callback, delay, cancelled: false }};
    timers.push(timer);
    return timer;
  }},
  clearTimer: timer => {{ timer.cancelled = true; }},
  setDeadlineTimer: (callback, delay) => {{
    const timer = {{ callback, delay, cancelled: false }};
    deadlineTimers.push(timer);
    return timer;
  }},
  clearDeadlineTimer: timer => {{ timer.cancelled = true; }},
  intervalMs: 8000,
  timeoutMs: 25,
}});

(async () => {{
  const first = poller.start();
  const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled);
  deadline.callback();
  await first;
  const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled);
  retry.callback();
  await new Promise(resolve => setImmediate(resolve));
  process.stdout.write(JSON.stringify({{
    calls,
    firstAborted: signals[0].aborted,
    errors,
    snapshots,
  }}));
}})();
"""

    assert run_node(script) == {
        "calls": 2,
        "firstAborted": True,
        "errors": [
            {"name": "TimeoutError", "message": "Live update timed out after 25ms."}
        ],
        "snapshots": ["timmy"],
    }


def test_forced_refresh_retires_stale_generation_without_late_overwrite():
    script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
let resolveFirst;
let firstSignal;
const rendered = [];
const errors = [];
let calls = 0;
const poller = createContextPoller({{
  fetchContext: (revisions, options) => {{
    calls += 1;
    if (calls === 1) {{
      firstSignal = options.signal;
      return new Promise(resolve => {{ resolveFirst = resolve; }});
    }}
    return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }});
  }},
  onSnapshot: snapshot => rendered.push(snapshot.context.user.login),
  onError: error => errors.push(error.name),
  setTimer: () => 1,
  clearTimer: () => {{}},
  setDeadlineTimer: () => 1,
  clearDeadlineTimer: () => {{}},
}});

(async () => {{
  const stale = poller.start();
  const fresh = poller.refresh({{ force: true }});
  await fresh;
  resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }});
  await stale;
  await new Promise(resolve => setImmediate(resolve));
  process.stdout.write(JSON.stringify({{
    calls,
    firstAborted: firstSignal.aborted,
    rendered,
    errors,
  }}));
}})();
"""

    assert run_node(script) == {
        "calls": 2,
        "firstAborted": True,
        "rendered": ["fresh"],
        "errors": [],
    }
