"""Regression coverage for anchor live prose sealing + row-level parser finalization."""

from __future__ import annotations

import json
import pathlib
import shutil
import subprocess

import pytest

ROOT = pathlib.Path(__file__).resolve().parent.parent
NODE = shutil.which("node")

EXTRACT_FN_JS = """
function hasFunction(src, name){
  return src.indexOf('function ' + name) !== -1;
}

function extractFn(src, name){
  const start = src.indexOf('function ' + name);
  if(start === -1) throw new Error(name + ' not found');
  const paramsStart = src.indexOf('(', start);
  let depth = 0;
  let paramsEnd = -1;
  for(let i = paramsStart; i < src.length; i++){
    if(src[i] === '(') depth += 1;
    else if(src[i] === ')'){
      depth -= 1;
      if(depth === 0){ paramsEnd = i; break; }
    }
  }
  const braceStart = src.indexOf('{', paramsEnd);
  if(braceStart === -1) throw new Error(name + ' body not found');
  depth = 0;
  for(let i = braceStart; i < src.length; i++){
    if(src[i] === '{') depth += 1;
    else if(src[i] === '}'){
      depth -= 1;
      if(depth === 0){
        return src.slice(start, i + 1);
      }
    }
  }
  throw new Error(name + ' body did not close');
}
"""

HARNESS_JS_TEMPLATE = r"""
const fs = require('fs');
const vm = require('vm');
const msgSrc = fs.readFileSync(__MSG_SRC__, 'utf8');
const uiSrc = fs.readFileSync(__UI_SRC__, 'utf8');
const anchorSrc = fs.readFileSync(__ANCHOR_SRC__, 'utf8');
let _extractSource = msgSrc;
let _anchorProseSmdCache;
let _anchorProseIncrementalNode;
let _finalizeAnchorProseIncrementalNode;
let _anchorSceneNodeForRow;

function extractFunction(name){
  return extractFn(_extractSource, name);
}
__EXTRACT_FN_JS__

function createHarness(options={}){
  const calls = {written:0, ended:0, failedEnds:0, created:0, tailFlush:0, tailClear:0, sanitize:0, enhance:0, identityClear:0};
  const cache = new Map();
  const failParserEnd = !!(options && options.failParserEnd);
  class FakeElement{
    constructor(tagName='div'){
      this.tagName = String(tagName || 'div').toUpperCase();
      this.children = [];
      this.attributes = Object.create(null);
      this.dataset = Object.create(null);
      this.parentNode = null;
      this.style = {};
      this._textContent = '';
      this._classSet = new Set();
      this.classList = {
        add: (name)=>this._classSet.add(String(name)),
        remove: (name)=>this._classSet.delete(String(name)),
        toggle: (name, on)=>{
          const key = String(name);
          if(on === false) this._classSet.delete(key);
          else this._classSet.add(key);
        },
        contains: (name)=>this._classSet.has(String(name)),
      };
    }
    appendChild(child){
      if(!child) return null;
      child.parentNode = this;
      this.children.push(child);
      return child;
    }
    get className(){
      return Array.from(this._classSet).join(' ');
    }
    set className(value){
      this._classSet.clear();
      String(value || '').split(/\s+/).filter(Boolean).forEach(v=>this._classSet.add(v));
    }
    setAttribute(name, value){
      const key = String(name);
      const val = String(value);
      this.attributes[key] = val;
      if(key === 'class'){
        this.className = val;
      }
      if(key.startsWith('data-')){
        const ds = key.slice(5).replace(/-([a-z])/g, (_, c)=>c.toUpperCase());
        this.dataset[ds] = val;
      }
    }
    getAttribute(name){
      return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
    }
    querySelector(selector){
      if(selector === '.msg-body'){
        const direct = this.children.find((c)=>c.className && c.className.split(' ').includes('msg-body')) || null;
        if(direct) return direct;
        if(typeof this._innerHTML === 'string'){
          const html = this._innerHTML || '';
          const open = '<div class=\"msg-body\">';
          const close = '</div>';
          if(html.startsWith(open) && html.endsWith(close)){
            return {
              textContent: html.slice(open.length, html.length - close.length),
            };
          }
        }
        return null;
      }
      return null;
    }
    get innerHTML(){
      return this._innerHTML || '';
    }
    set innerHTML(value){
      this._innerHTML = String(value);
      this.textContent = this._innerHTML;
    }
    get textContent(){
      return this._textContent || '';
    }
    set textContent(value){
      this._textContent = String(value);
    }
  }

  global.document = {
    createElement(tag){
      return new FakeElement(tag);
    },
    createTextNode(value){
      const node = new FakeElement('#text');
      node.textContent = String(value || '');
      return node;
    },
  };

  function bindParser(renderer, parser, el){
    if(!parser || !el) return;
    parser._body = el;
    el.__smdParser = parser;
  }

  global.window = {
    _fadeTextEffect: false,
    _shouldUseLiveProseFade: ()=>false,
  };
  window.__anchorProseIncrementalNode = null;
  window._anchorProseSmdCache = cache;
  window.smd = {
    HREF: 'href',
    SRC: 'src',
    parser(){
      calls.created += 1;
      return {id: calls.created, ended: false, pending: ''};
    },
    parser_write(parser, text){
      calls.written += 1;
      if(!parser || parser.ended || !parser._body) return;
      const chunk = String(text || '');
      if(!chunk) return;
      const flushNow = chunk.length > 1 ? chunk.slice(0, -1) : '';
      if(flushNow) parser._body.textContent += flushNow;
      parser.pending += chunk.slice(-1);
    },
    parser_end(parser){
      calls.ended += 1;
      if(failParserEnd){
        calls.failedEnds += 1;
        throw new Error('parser_end failure');
      }
      if(!parser || parser.ended || !parser._body) return;
      parser._body.textContent += parser.pending || '';
      parser.pending = '';
      parser.ended = true;
    },
  };

  global._safeSmdRenderer = ()=>({set_attr(){},add_text(){},add_token(){},end_token(){}});
  global._smdRendererWithoutUnderscoreEmphasis = (renderer)=>renderer;
  global._streamFadeRenderer = (body)=>({body,set_attr(){},add_text(){},add_token(){},end_token(){}});
  global._smdBindParserIdentity = bindParser;
  global._smdClearParserIdentity = (el, parser)=>{
    if(!el || !parser || el.__smdParser !== parser) return;
    calls.identityClear += 1;
    delete el.__smdParser;
  };
  global._smdMediaTailFlush = ()=>{ calls.tailFlush += 1; };
  global._smdMediaTailClear = ()=>{ calls.tailClear += 1; };
  global._sanitizeSmdLinks = ()=>{ calls.sanitize += 1; };
  global.enhanceMarkdownTables = ()=>{ calls.enhance += 1; };
  global.renderMd = (text)=>String(text || '');
  global.esc = (value)=>String(value || '');

  vm.runInThisContext(anchorSrc, {filename:'assistant_turn_anchors.js'});
  if(!window.HermesAssistantTurnAnchors){
    throw new Error('assistant_turn_anchors.js did not expose HermesAssistantTurnAnchors');
  }

  _anchorProseSmdCache = cache;
  window._anchorProseSmdCache = cache;
  _extractSource = msgSrc;
  eval(extractFunction('_anchorProseIncrementalNode'));
  eval(extractFunction('_finalizeAnchorProseIncrementalNode'));
  _extractSource = uiSrc;
  eval(extractFunction('_anchorSceneNodeForRow'));
  window.__anchorProseIncrementalNode = _anchorProseIncrementalNode;

  return {
    calls,
    cache,
    anchorApi: window.HermesAssistantTurnAnchors,
    renderRow(row){
      return _anchorSceneNodeForRow(row, {settled:false});
    },
  };
}

function runCase(name){
  const harness = createHarness();
  const row = (status, text)=>({role:'prose', local_id:'live-prose:1', status, text});

  if(name === 'running_row_does_not_finalize'){
    const node = harness.renderRow(row('running', 'running text.'));
    return {
      finalizeCount: harness.calls.ended,
      hasNode: !!node,
    };
  }

  if(name === 'completed_row_finalizes_once'){
    harness.renderRow(row('running', 'streamed final'));
    const node = harness.renderRow(row('completed', 'streamed final.'));
    const body = node && node.querySelector('.msg-body');
    return {
      endCount: harness.calls.ended,
      bodyText: body ? body.textContent : '',
      sanitizeCalls: harness.calls.sanitize,
      enhanceCalls: harness.calls.enhance,
      tailFlush: harness.calls.tailFlush,
      tailClear: harness.calls.tailClear,
    };
  }

  if(name === 'completed_row_rerender_is_idempotent'){
    harness.renderRow(row('completed', 'idempotent.'));
    const node = harness.renderRow(row('completed', 'idempotent.'));
    const body = node && node.querySelector('.msg-body');
    return {
      endCount: harness.calls.ended,
      bodyText: body ? body.textContent : '',
      createdParsers: harness.calls.created,
      writeCalls: harness.calls.written,
    };
  }

  if(name === 'finalized_row_rebuilds_on_changed_text'){
    harness.renderRow(row('completed', 'first.'));
    const node = harness.renderRow(row('completed', 'first!!'));
    const body = node && node.querySelector('.msg-body');
    return {
      endCount: harness.calls.ended,
      createdParsers: harness.calls.created,
      bodyText: body ? body.textContent : '',
      writeCalls: harness.calls.written,
    };
  }

  if(name === 'interim_assistant_tool_boundary_keeps_interim_prose_authoritative_full_text'){
    const registry = harness.anchorApi.createAssistantTurnAnchorRegistry({
      session_id:'session-final-char',
      turn_id:'turn-final-char',
      run_id:'run-final-char',
      stream_id:'stream-final-char',
      local_id:'assistant-msg-final-char',
      source_message_refs:['assistant-msg-final-char'],
    });
    const context = {
      session_id:'session-final-char',
      turn_id:'turn-final-char',
      run_id:'run-final-char',
      stream_id:'stream-final-char',
    };
    const authoritativeText = 'Streaming with punctuation.';
    harness.anchorApi.applyAssistantTurnAnchorSourceEvent(registry,{
      source_event_type:'interim_assistant',
      seq:1,
      local_id:'prose-final-char',
      payload:{text:authoritativeText},
      ...context,
    });
    harness.anchorApi.applyAssistantTurnAnchorSourceEvent(registry,{
      source_event_type:'tool',
      seq:2,
      local_id:'tool-final-char',
      payload:{text:'tool started', name:'search', tool_call_id:'tool-call-1'},
      ...context,
    });
    const scene = harness.anchorApi.projectAssistantTurnAnchorActivityScene(registry,{mode:'compact_worklog'});
    const rows = Array.isArray(scene && scene.activity_rows) ? scene.activity_rows : [];
    const proseActivityRow = rows.find((row)=>row.role === 'prose') || null;
    const toolActivityRow = rows.find((row)=>row.role === 'tool') || null;
    const proseNode = proseActivityRow ? harness.renderRow(proseActivityRow) : null;
    const proseBody = proseNode && proseNode.querySelector('.msg-body');
    return {
      sceneRowsCount: rows.length,
      proseStatus: proseActivityRow ? proseActivityRow.status : null,
      proseSourceText: proseActivityRow ? proseActivityRow.text : null,
      proseRenderedText: proseBody ? proseBody.textContent : null,
      toolStatus: toolActivityRow ? toolActivityRow.status : null,
      toolSourceEventType: toolActivityRow ? toolActivityRow.source_event_type : null,
      finalizeCount: harness.calls.ended,
      authoritativeText,
    };
  }

  if(name === 'tool_boundary_completes_row'){
    harness.renderRow(row('running', 'pre tool'));
    const node = harness.renderRow(row('completed', 'pre tool.'));
    const body = node && node.querySelector('.msg-body');
    return {
      finalizeCount: harness.calls.ended,
      bodyText: body ? body.textContent : '',
      writeCalls: harness.calls.written,
      createdParsers: harness.calls.created,
    };
  }

  if(name === 'completed_row_end_failure_falls_back_to_render_md'){
    const failingHarness = createHarness({failParserEnd:true});
    const node = failingHarness.renderRow({
      role:'prose',
      local_id:'live-prose:1',
      status:'completed',
      text:'streamed final.',
    });
    const body = node && node.querySelector('.msg-body');
    return {
      finalizeCount: failingHarness.calls.ended,
      failedEndCount: failingHarness.calls.failedEnds,
      bodyText: body ? body.textContent : '',
      renderCount: failingHarness.calls.created,
      writeCalls: failingHarness.calls.written,
      cacheSize: failingHarness.cache.size,
    };
  }

  throw new Error('unknown case: ' + name);
}

const output = runCase("__CASE__");
console.log(JSON.stringify(output));
"""


def _run_scenario(case_name: str) -> dict:
    assert NODE is not None, "node is required for anchor incremental harness tests"
    case = str(case_name)
    script = HARNESS_JS_TEMPLATE
    script = script.replace("__MSG_SRC__", json.dumps(str(ROOT / 'static' / 'messages.js')))
    script = script.replace("__UI_SRC__", json.dumps(str(ROOT / 'static' / 'ui.js')))
    script = script.replace("__ANCHOR_SRC__", json.dumps(str(ROOT / 'static' / 'assistant_turn_anchors.js')))
    script = script.replace("__EXTRACT_FN_JS__", EXTRACT_FN_JS)
    script = script.replace("__CASE__", case)
    result = subprocess.run([NODE, "-e", script], cwd=ROOT, text=True, capture_output=True, check=False)
    assert result.returncode == 0, result.stderr or result.stdout
    return json.loads(result.stdout.strip().splitlines()[-1])


def _messages_js_source() -> str:
    return (ROOT / "static" / "messages.js").read_text(encoding="utf-8")


@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
def test_running_anchor_prose_does_not_finalize_parser():
    data = _run_scenario("running_row_does_not_finalize")
    assert data["finalizeCount"] == 0


@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
def test_completed_anchor_row_finalizes_once_and_runs_post_end_cleanup():
    data = _run_scenario("completed_row_finalizes_once")
    assert data["endCount"] == 1
    assert data["bodyText"] == "streamed final."
    assert data["sanitizeCalls"] >= 1
    assert data["enhanceCalls"] >= 1
    assert data["tailFlush"] >= 1
    assert data["tailClear"] >= 1


@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
def test_completed_row_rerender_is_idempotent_after_end():
    data = _run_scenario("completed_row_rerender_is_idempotent")
    assert data["endCount"] == 1
    assert data["bodyText"] == "idempotent."
    assert data["createdParsers"] == 1
    assert data["writeCalls"] >= 1


@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
def test_changed_text_after_finalized_row_rebuilds_from_full_authoritative_text():
    data = _run_scenario("finalized_row_rebuilds_on_changed_text")
    assert data["endCount"] == 2
    assert data["createdParsers"] == 2
    assert data["bodyText"] == "first!!"
    assert data["writeCalls"] >= 2


@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
def test_tool_boundary_sealing_updates_row_to_completed_for_finalize():
    messages_source = _messages_js_source()
    assert "_upsertAnchorProcessProse(pendingDisplayTextBeforeTool,{sealed:true})" in messages_source
    assert "_upsertAnchorProcessProse(pendingDisplayTextBeforeComplete,{sealed:true})" in messages_source
    assert "status:options.sealed?'completed':'running'" in messages_source
    data = _run_scenario("tool_boundary_completes_row")
    assert data["finalizeCount"] == 1
    assert data["bodyText"] == "pre tool."
    assert data["createdParsers"] == 1
    assert data["writeCalls"] >= 1


@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
def test_anchor_prose_parser_end_failure_falls_back_to_full_renderMd():
    data = _run_scenario("completed_row_end_failure_falls_back_to_render_md")
    assert data["finalizeCount"] == 1
    assert data["failedEndCount"] == 1
    assert data["renderCount"] == 1
    assert data["bodyText"] == "streamed final."
    assert data["cacheSize"] == 0


@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
def test_interim_assistant_to_tool_boundary_must_render_full_authoritative_punctuation():
    data = _run_scenario("interim_assistant_tool_boundary_keeps_interim_prose_authoritative_full_text")
    assert data["sceneRowsCount"] >= 2
    assert data["proseStatus"] == "completed"
    assert data["toolStatus"] == "running"
    assert data["toolSourceEventType"] == "tool"
    assert data["proseSourceText"] == data["authoritativeText"]
    assert data["proseRenderedText"] == data["authoritativeText"]
    assert data["finalizeCount"] == 1
