const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');

const root = __dirname;
const read = (name) => fs.readFileSync(path.join(root, name), 'utf8');
const inject = read('js/instagram_inject.js');
const content = read('content_instagram.js');
const side = read('side.js');
const manifest = JSON.parse(read('manifest.json'));

assert.strictEqual(manifest.version, '2.3.8.276');
assert.strictEqual(manifest.version_name, '2.3.8.276');

function extractFunction(source, name) {
  const start = source.indexOf(`function ${name}(`);
  assert.ok(start >= 0, `${name} must exist`);
  const open = source.indexOf('{', start);
  let depth = 0;
  let quote = '';
  let escaped = false;
  for (let i = open; i < source.length; i += 1) {
    const ch = source[i];
    if (quote) {
      if (escaped) escaped = false;
      else if (ch === '\\') escaped = true;
      else if (ch === quote) quote = '';
      continue;
    }
    if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
    if (ch === '{') depth += 1;
    else if (ch === '}') {
      depth -= 1;
      if (depth === 0) return source.slice(start, i + 1);
    }
  }
  throw new Error(`${name} closing brace not found`);
}

// Explore popup round-trips keep the whole Explore media map in the same SPA document.
const carryStart = inject.indexOf('function teStatsShortcodeFromUrl');
const carryEnd = inject.indexOf('function teIgBlueLabel', carryStart);
assert.ok(carryStart >= 0 && carryEnd > carryStart, 'SPA carry helper block missing');
const carryCtx = { URL, location: { href: 'https://www.instagram.com/explore/', origin: 'https://www.instagram.com' }, Object, String, dbg: () => {} };
vm.createContext(carryCtx);
vm.runInContext(`
  let n={TARGET:{code:'TARGET',id:'MID1',pk:'PK1',username:'owner'},OTHER:{code:'OTHER',id:'MID2',pk:'PK2',username:'other_owner'}};
  let t={old:1},o={old:1},i={MID1:'TARGET',MID2:'OTHER'},r={PK1:'TARGET',PK2:'OTHER'},teShareByCode={TARGET:3};
  let teStatsMediaScopeKey='https://www.instagram.com|/explore/|',teStatsMediaScopeMode='explore';
  let teStatsLastDispatchSignature='old',teStatsLastDispatchAt=10,teStatsSpaCarryByCode={},teStatsSpaCarryOrder=[];
  ${inject.slice(carryStart, carryEnd)}
  teStatsResetMediaScope(new URL('https://www.instagram.com/p/TARGET/'));
  const afterPopup={keys:Object.keys(n),mode:teStatsMediaScopeMode,scope:teStatsMediaScopeKey};
  teStatsResetMediaScope(new URL('https://www.instagram.com/explore/'));
  this.result={afterPopup,afterReturn:{keys:Object.keys(n),mode:teStatsMediaScopeMode,scope:teStatsMediaScopeKey},mapId1:i.MID1,mapId2:i.MID2,carry:Object.keys(teStatsSpaCarryByCode)};
`, carryCtx);
const carryResult = JSON.parse(JSON.stringify(carryCtx.result));
assert.deepStrictEqual(carryResult.afterPopup.keys.sort(), ['OTHER','TARGET']);
assert.strictEqual(carryResult.afterPopup.mode, 'detail');
assert.deepStrictEqual(carryResult.afterReturn.keys.sort(), ['OTHER','TARGET']);
assert.strictEqual(carryResult.afterReturn.mode, 'explore');
assert.strictEqual(carryResult.mapId1, 'TARGET');
assert.strictEqual(carryResult.mapId2, 'OTHER');
assert.ok(carryResult.carry.includes('OTHER'));

// fetch, history, DOM and video events are all actual retry triggers.
for (const token of [
  'installIgblueFetchCaptureV264',
  'captureClone.text()',
  'installIgblueResponseCaptureV268',
  'Response.prototype.json',
  'Response.prototype.text',
  'teStatsParseFetchTextV264',
  'installIgblueSpaMonitorV264',
  'history[name]=function()',
  'document.addEventListener("play"',
  'document.addEventListener("loadeddata"',
  'new MutationObserver',
  'setInterval((()=>teStatsCheckSpaUrlV264("interval")),250)',
  'igblue_current_media_account_target',
  'spa_url_change_pending'
]) assert.ok(inject.includes(token), `missing SPA trigger: ${token}`);


// Core injection functions keep explicit Korean role comments for later analysis.
for (const comment of [
  'k(): 빈 인젝션 카드 틀을 한 번 생성',
  'N(): 현재 미디어의 code를 카드의 data-code에 다시 연결',
  'S(code): 현재 데이터 맵에서 해당 shortcode 정보 조회',
  'O(): 연결된 미디어 정보를 기존 카드에 다시 표시',
  'V(): 1초마다 현재 URL·DOM·데이터를 다시 대조하며 위 과정을 반복'
]) assert.ok(inject.includes(comment), `missing function role comment: ${comment}`);

// Hover is attached to the real Instagram media host and resolves through data-code -> S(code).
for (const token of [
  'function teMainBindHoverAccountHost',
  'host.addEventListener("pointermove"',
  'function teMainHoverAccountFromCard',
  'const media=S(code)',
  'teMainBindHoverAccountHost(n,e,t)',
  'teMainRefreshHoverAccountTargetFromCard(t,"data_code_relinked")'
]) assert.ok(inject.includes(token), `missing linked hover flow: ${token}`);
assert.ok(!inject.includes('function usernameFromCard(card)'), 'rendered account row parser must be removed');
assert.ok(!content.includes('source: "content_thumbnail_hover"'), 'content script must not override linked hover with rendered text');


// Hover changes only after 0.2 seconds; leaving during Side-button travel keeps the confirmed card.
const hoverStart = inject.indexOf('let teMainHoverAccountActiveCard=');
const hoverEnd = inject.indexOf('/*\n * k(): 빈 인젝션 카드 틀을 한 번 생성', hoverStart);
assert.ok(hoverStart >= 0 && hoverEnd > hoverStart, 'linked hover helper block missing');
const hoverEvents = [];
const timers = [];
const attrs = { 'data-code': 'CODE_A' };
const fakeCard = {
  getAttribute: (name) => attrs[name] || '',
  setAttribute: (name, value) => { attrs[name] = String(value); },
  removeAttribute: (name) => { delete attrs[name]; }
};
const hostHandlers = {};
const fakeHost = {
  addEventListener: (name, fn) => { hostHandlers[name] = fn; },
  removeEventListener: () => {},
  contains: () => false,
};
const hoverCtx = {
  String, Date, Map,
  location: { href: 'https://www.instagram.com/explore/' },
  window: {},
  setTimeout: (fn, ms) => { timers.push({fn, ms, cleared:false}); return timers.length; },
  clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; },
  document: {
    getElementById: () => ({}),
    documentElement: {},
    head: null,
    dispatchEvent: (ev) => { hoverEvents.push(ev.detail); }
  },
  CustomEvent: function CustomEvent(type, init) { this.type = type; this.detail = init && init.detail; },
  teMainRankLabelMap: new Map(),
  teMainOwnerText: (media) => media && media.username || '',
  S: (code) => ({ CODE_A: { username: 'owner_a' }, CODE_B: { username: 'owner_b' } })[code] || null
};
vm.createContext(hoverCtx);
vm.runInContext(`${inject.slice(hoverStart, hoverEnd)}; this.bind=teMainBindHoverAccountHost; this.refreshCard=teMainRefreshHoverAccountTargetFromCard;`, hoverCtx);
hoverCtx.bind(fakeCard, null, fakeHost);
hostHandlers.pointerenter();
assert.strictEqual(hoverEvents.length, 0, 'hover must not change immediately');
assert.strictEqual(timers[0].ms, 200);
timers[0].fn();
assert.strictEqual(hoverEvents[0].username, 'owner_a');
assert.strictEqual(attrs['data-igblue-account-hover'], '1');
hostHandlers.pointerleave({relatedTarget:null});
assert.strictEqual(hoverEvents.length, 1, 'confirmed hover must remain while moving to Side');
assert.strictEqual(attrs['data-igblue-account-hover'], '1');
attrs['data-code'] = 'CODE_B';
hoverCtx.refreshCard(fakeCard, 'data_code_relinked');
assert.strictEqual(hoverEvents[hoverEvents.length - 1].username, 'owner_b');
assert.strictEqual(hoverEvents[hoverEvents.length - 1].shortcode, 'CODE_B');

// A detail/Reels shortcode must use the current-media owner and must not fall back to stale hover state.
const shortcodeFn = extractFunction(content, 'igblueShortcodeFromHrefV264');
const resolverFn = extractFunction(content, 'igblueReadCurrentAccountTarget');
const baseDocument = { querySelector: () => null, querySelectorAll: () => [] };
const resolvedCtx = {
  URL, Math,
  location: { href: 'https://www.instagram.com/reels/DbHnKd5BXOf/', origin: 'https://www.instagram.com' },
  window: { innerWidth: 1000, innerHeight: 800 }, document: baseDocument,
  localStorage: { getItem: () => '' },
  igblueHoveredAccountTarget: { username: 'old_owner', shortcode: 'DXO9ApBkzXP', href: 'https://www.instagram.com/reels/DXO9ApBkzXP/', ts: Date.now() },
  igblueCurrentMediaAccountTargetV264: { username: 'new_owner', shortcode: 'DbHnKd5BXOf', source: 'video_play', href: 'https://www.instagram.com/reels/DbHnKd5BXOf/', ts: Date.now() }
};
vm.createContext(resolvedCtx);
vm.runInContext(`${shortcodeFn}; ${resolverFn}; this.result=igblueReadCurrentAccountTarget();`, resolvedCtx);
assert.strictEqual(resolvedCtx.result.ok, true);
assert.strictEqual(resolvedCtx.result.username, 'new_owner');
assert.strictEqual(resolvedCtx.result.shortcode, 'DbHnKd5BXOf');

const pendingCtx = {
  URL, Math,
  location: { href: 'https://www.instagram.com/p/Dah26s-hU9W/', origin: 'https://www.instagram.com' },
  window: { innerWidth: 1000, innerHeight: 800 }, document: baseDocument,
  localStorage: { getItem: () => '' },
  igblueHoveredAccountTarget: { username: 'stale_owner', shortcode: 'OLD', href: 'https://www.instagram.com/explore/', ts: Date.now() },
  igblueCurrentMediaAccountTargetV264: { username: '', shortcode: 'Dah26s-hU9W', source: 'spa_current_media_pending', href: 'https://www.instagram.com/p/Dah26s-hU9W/', ts: Date.now() }
};
vm.createContext(pendingCtx);
vm.runInContext(`${shortcodeFn}; ${resolverFn}; this.result=igblueReadCurrentAccountTarget();`, pendingCtx);
assert.strictEqual(pendingCtx.result.ok, false);
assert.strictEqual(pendingCtx.result.shortcode, 'Dah26s-hU9W');
assert.strictEqual(pendingCtx.result.source, 'current_media_pending');

// Side clears the old account label before starting the next async target lookup.
const refreshPos = side.indexOf('async function teAutoPyRefreshCurrentAccountAddTarget()');
const clearPos = side.indexOf('teAutoPySetCurrentAccountAddTarget("", "target_url_changed", "");', refreshPos);
const runningPos = side.indexOf('if (teCurrentAccountAddRefreshRunning)', refreshPos);
assert.ok(refreshPos >= 0 && clearPos > refreshPos && runningPos > clearPos, 'target URL clear must happen before in-flight guard');
assert.ok(side.includes('teCurrentAccountAddTimer = setInterval(teAutoPyRefreshCurrentAccountAddTarget, 400);'));

// Obsolete visible No navigation is hidden but internal automation IDs remain, and DOM diagnostics are copyable.
assert.ok(!inject.includes('igblue-nav-box'));
for (const id of ['IGBLUE_NoNav_Current','IGBLUE_NoNav_Next']) assert.ok(!inject.includes(id), `visual automation ID survived: ${id}`);
assert.ok(inject.includes('function teMainNoNavCurrent') && inject.includes('function teMainNoNavNext'));
assert.ok(inject.includes('IGBLUE/internal_no_nav_state'));
assert.ok(content.includes('internal_handler_found: true'));
assert.ok(!content.includes('querySelector("#IGBLUE_NoNav_'));
assert.ok(inject.includes('id="IGBLUE_DomDiagnosticCopy"'));
assert.ok(inject.includes('function teMainDomDiagnosticPayload()'));
assert.ok(inject.includes('igblue_dom_diagnostic_v268'));
assert.ok(inject.includes('injection_check_log'));
assert.ok(inject.includes('media_map_count'));
assert.ok(inject.includes('capture_counters'));
assert.ok(inject.includes('first_missing_html'));

console.log('OK test_ext_spa_injection v272');
