# src/markdown_helper.py
from __future__ import annotations

import os
import re
import webbrowser

from imgui_bundle import imgui_md, imgui, immapp, imgui_color_text_edit as ed

from pathlib import Path
from typing  import Optional, Dict, Callable

from src import theme_2

from src.module_loader import _require_warmed

# NOTE: src.markdown_table is NOT imported at module level. The GFM table
# rendering branch is feature-gated (startup_speedup_20260606 Phase 5C);
# the lookup happens inside MarkdownRenderer.render() via _require_warmed.
# If you need parse_tables/render_table elsewhere in this file, do the
# lookup there too. The warmup on AppController's _io_pool pre-loads
# src.markdown_table so the first render() call is O(1).


def _get_language_id(name: str):
 """Get a language identifier for ImGuiColorTextEdit.

 Compatible with both imgui-bundle 1.92.5 (LanguageDefinitionId enum)
 and 1.92.801+ (Language factory functions returning a Language object).
 Returns None for "no language" or unknown names.
 """
 if not name or name == "none":
  return None
 # Prefer the newer API (1.92.801+) which uses factory functions.
 if hasattr(ed.TextEditor, "Language"):
  lang_class = ed.TextEditor.Language
  if hasattr(lang_class, name):
   factory = getattr(lang_class, name)
   if callable(factory):
    return factory()
 # Fall back to the older API (1.92.5) which exposes an enum.
 if hasattr(ed.TextEditor, "LanguageDefinitionId"):
  lang_id_class = ed.TextEditor.LanguageDefinitionId
  if hasattr(lang_id_class, name):
   return getattr(lang_id_class, name)
 return None


def _set_editor_language(editor, lang_obj) -> None:
 """Set the editor's language via whichever API is available.

 1.92.801+: editor.set_language(obj). 1.92.5: editor.set_language_definition(obj).
 No-op when lang_obj is None (used to skip the call for unknown languages).
 """
 if lang_obj is None: return

 if   hasattr(editor, "set_language"):            editor.set_language(lang_obj)
 elif hasattr(editor, "set_language_definition"): editor.set_language_definition(lang_obj)

class MarkdownRenderer:
 """
 Hybrid Markdown renderer that uses imgui_md for text/headers
 and ImGuiColorTextEdit for syntax-highlighted code blocks.
 """
 def __init__(self):
  """
  [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__]
  """
  self.options = imgui_md.MarkdownOptions()
  # Base path for fonts (Inter family)
  self.options.font_options.font_base_path = "fonts/Inter"
  self.options.font_options.regular_size   = 18.0

  # Configure callbacks
  self.options.callbacks.on_open_link = self._on_open_link

  # Cache for TextEditor instances to maintain state
  self._editor_cache: Dict[tuple[str, int], ed.TextEditor] = {}
  # Parallel cache tracking the current language tag per editor (avoids per-frame
  # set_language calls and is robust against imgui-bundle naming differences).
  self._editor_lang_cache: Dict[tuple[str, int], Optional[str]] = {}
  self._max_cache_size = 100

  # Optional callback for custom local link handling (e.g., opening in IDE)
  self.on_local_link: Optional[Callable[[str], None]] = None

  # Apply the current theme's syntax palette on construction so new
  # editors we create pick up the right colors. The renderer is re-created
  # when the theme changes (see theme_2 module-load behavior).
  palette_id = theme_2.get_syntax_palette_for_theme(theme_2.get_current_palette())
  theme_2.apply_syntax_palette(palette_id)

  # Language mapping for ImGuiColorTextEdit
  self._lang_map = {
   "python": _get_language_id("python"),
   "py":     _get_language_id("python"),
   "json":   _get_language_id("json"),
   "cpp":    _get_language_id("cpp"),
   "c++":    _get_language_id("cpp"),
   "c":      _get_language_id("c"),
   "lua":    _get_language_id("lua"),
   "sql":    _get_language_id("sql"),
   "cs":     _get_language_id("cs"),
   "c#":     _get_language_id("cs"),
  }

 def _on_open_link(self, url: str) -> None:
  """Handle link clicks in Markdown."""
  if url.startswith("http"):
   webbrowser.open(url)
  else:
   # Try to handle as a local file path
   try:
    p = Path(url)
    if p.exists():
     if self.on_local_link:
      self.on_local_link(str(p.absolute()))
     else:
      # Fallback to OS default handler
      webbrowser.open(str(p.absolute()))
    else:
     print(f"Link target does not exist: {url}")
   except Exception as e:
    print(f"Error opening link {url}: {e}")

 def render(self, text: str, context_id: str = "default") -> None:
  """
  Render Markdown text with code block interception and GFM table substitution.
  [C: src/theme_2.py:render_post_fx, tests/test_theme_nerv_alert.py:test_alert_pulsing_render_active, tests/test_theme_nerv_alert.py:test_alert_pulsing_render_inactive, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_alert_pulsing_render, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_crt_filter_disabled, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_crt_filter_render]
  """
  # Lazy lookup of src.markdown_table (Phase 5C). Warmup has already loaded
  # it on AppController's _io_pool, so this is an O(1) sys.modules get.
  markdown_table = _require_warmed("src.markdown_table")
  parse_tables = markdown_table.parse_tables
  render_table = markdown_table.render_table
  if not text: return
  text = self._normalize_bullet_delimiters(text)
  text = self._normalize_nested_list_endings(text)
  text = self._normalize_list_continuations(text)
  blocks = parse_tables(text)
  lines = text.splitlines(keepends=True)
  if not lines: return

  table_at_line: dict[int, int] = {b.span[0]: i         for i, b in enumerate(blocks)}
  table_end:     dict[int, int] = {b.span[0]: b.span[1] for i, b in enumerate(blocks)}

  md_buf:   list[str] = []
  code_buf: list[str] = []
  block_idx    = 0
  in_fence     = False
  fence_marker = ""

  def flush_md() -> None:
   if md_buf:
    chunk = "".join(md_buf)
    if chunk:
     imgui_md.render(chunk)
     imgui.spacing()
    md_buf.clear()

  def flush_code() -> None:
   nonlocal block_idx
   if code_buf:
    chunk = "".join(code_buf)
    self._render_code_block(chunk, context_id, block_idx)
    block_idx += 1
    code_buf.clear()

  i = 0
  while i < len(lines):
   line     = lines[i]
   stripped = line.lstrip().rstrip("\r\n")
   if stripped.startswith("```"):
    if not in_fence:
     in_fence     = True
     fence_marker = stripped[:3]
     flush_md()
     code_buf.append(line)
     i += 1
     continue
    if fence_marker and stripped.startswith(fence_marker):
     in_fence = False
     code_buf.append(line)
     flush_code()
     fence_marker = ""
     i += 1
     continue
    code_buf.append(line)
    i += 1
    continue
   if in_fence:
    code_buf.append(line)
    i += 1
    continue
   if i in table_at_line:
    flush_md()
    block = blocks[table_at_line[i]]
    try:
     render_table(block)
    except Exception as e:
     # Fallback: if table rendering fails, just append lines to md_buf
     for line_idx in range(block.span[0], block.span[1]):
      md_buf.append(lines[line_idx])
    i = table_end[i]
    continue
   md_buf.append(line)
   i += 1

  flush_md()
  flush_code()

 def _normalize_bullet_delimiters(self, text: str) -> str:
   """Convert '*' list markers to '-' before passing to imgui_md.

   Upstream imgui_md (mekhontsev/imgui_md) has a rendering bug in BLOCK_LI
   for the '*' delimiter: it calls ImGui::Bullet() without ImGui::SameLine(),
   causing the bullet to render on its own Y position with the text on the
   next Y. The '-' delimiter uses Text+SameLine which works correctly.
   Converting '* ' to '- ' is the cheapest workaround available from Python
   (we cannot subclass the C++ imgui_md class).
   [C: src/markdown_helper.py:MarkdownRenderer.render]
   """
   return re.sub(r"(?m)^([ \t]*)\*[ \t]+", r"\1- ", text)

 def _normalize_nested_list_endings(self, text: str) -> str:
  """Insert blank lines before non-list text that follows a nested list item.

  Workaround for imgui_md (mekhontsev/imgui_md) BLOCK_UL exit which only
  calls ImGui::NewLine() for top-level list endings. For nested list
  endings, no NewLine is emitted, so the next text starts at the same Y
  as the last list item (overlap). Inserting a blank line forces a
  paragraph break. Cannot fix the upstream C++ from Python.
  [C: src/markdown_helper.py:MarkdownRenderer.render]
  """
  lines = text.split("\n")
  out: list[str] = []
  for i, line in enumerate(lines):
   if (
    line.strip()
    and not re.match(r"^\s*[-*+\d]", line)
    and i > 0
    and re.match(r"^\s*[-*+\d]", lines[i - 1])
   ):
    prev_indent = len(lines[i - 1]) - len(lines[i - 1].lstrip())
    curr_indent = len(line) - len(line.lstrip())
    if curr_indent < prev_indent:
     out.append("")
   out.append(line)
  return "\n".join(out)

 def _normalize_list_continuations(self, text: str) -> str:
   """Strip blank lines between a list item and its indented continuation.

   imgui_md (mekhontsev/imgui_md) BLOCK_P does NOT call ImGui::NewLine()
   when m_list_stack is non-empty. So a multi-paragraph list item of the
   shape:
       - first paragraph

           continuation paragraph
   renders BOTH paragraphs at the same Y (overlap), because the second
   BLOCK_P enters/exits without advancing the cursor.

   WORKAROUND: Remove the blank line so the continuation becomes a lazy
   continuation of the first paragraph (single BLOCK_P, proper line wrap,
   no overlap). Trade-off: the user cannot have separate paragraphs within
   a single list item. Acceptable for our use case.
   [C: src.markdown_helper:MarkdownRenderer.render]
   """
   lines = text.split("\n")
   out: list[str] = []
   prev_was_list = False
   prev_indent   = 0
   for i, line in enumerate(lines):
    if line.strip():
     out.append(line)
     if re.match(r"^\s*[-*+\d]\s+", line):
      prev_was_list = True
      prev_indent   = len(line) - len(line.lstrip())
     else:
      prev_was_list = False
     continue
    if not prev_was_list:
     out.append(line)
     continue
    j = i + 1
    while j < len(lines) and not lines[j].strip():
     j += 1
    if j >= len(lines):
     out.append(line)
     prev_was_list = False
     continue
    next_line    = lines[j]
    curr_indent  = len(next_line) - len(next_line.lstrip())
    is_next_list = bool(re.match(r"^\s*[-*+\d]", next_line))
    if curr_indent > prev_indent and not is_next_list: continue
    out.append(line)
    prev_was_list = False
   return "\n".join(out)

 def render_unindented(self, text: str) -> None:
  """Render Markdown text with automatic unindentation."""
  imgui_md.render_unindented(text)

 def _render_code_block(self, block: str, context_id: str, block_idx: int) -> None:
  """Render a code block using TextEditor for syntax highlighting."""
  lines    = block.strip('`').split('\n')
  lang_tag = lines[0].strip().lower() if lines else ""

  # Heuristic to separate lang tag from code
  if lang_tag and lang_tag not in self._lang_map and not self._is_likely_lang_tag(lang_tag):
   lang_tag = ""
   code = '\n'.join(lines)
  else:
   code = '\n'.join(lines[1:]) if len(lines) > 1 else ""

  if not lang_tag:
   lang_tag = self.detect_language(code)

  # Cache management
  if len(self._editor_cache) > self._max_cache_size:
   # Simple LRU-ish: just clear it all if it gets too big
   self._editor_cache.clear()
   self._editor_lang_cache.clear()

  cache_key = (context_id, block_idx)
  if cache_key not in self._editor_cache:
   editor = ed.TextEditor()
   editor.set_read_only_enabled(True)
   editor.set_show_line_numbers_enabled(True)
   
   # Explicitly set palette on instance to be sure
   from src import theme_2
   p_name = theme_2.get_syntax_palette_for_theme(theme_2.get_current_palette())
   if hasattr(ed.TextEditor, "PaletteId"):
    p_id = getattr(ed.TextEditor.PaletteId, p_name, None)
    if p_id is not None:
     editor.set_palette(p_id)
     
   self._editor_cache[cache_key]      = editor
   self._editor_lang_cache[cache_key] = None

  editor       = self._editor_cache[cache_key]
  current_lang = self._editor_lang_cache[cache_key]

  # Sync text and language. None means "no language set" (skip the call).
  lang_id = self._lang_map.get(lang_tag)

  # Robust check to avoid re-setting text every frame (which resets scroll)
  curr_text = editor.get_text().replace('\r\n', '\n').strip()
  if curr_text != code.replace('\r\n', '\n').strip():
   editor.set_text(code)
   if current_lang != lang_tag:
    _set_editor_language(editor, lang_id)
    self._editor_lang_cache[cache_key] = lang_tag
  elif current_lang != lang_tag:
   _set_editor_language(editor, lang_id)
   self._editor_lang_cache[cache_key] = lang_tag

  # Dynamic height calculation
  line_count  = code.count('\n') + 1
  line_height = imgui.get_text_line_height()
  height      = (line_count * line_height) + 20
  height      = min(max(height, 40), 500)

  editor.render(f"##code_{context_id}_{block_idx}", a_size=imgui.ImVec2(0, height))

 def render_code(self, code: str, lang: str = "", context_id: str = "default", block_idx: int = 0) -> None:
  """Render a code block directly with syntax highlighting."""
  # Wrap in fake markdown markers for the internal renderer
  self._render_code_block(f"```{lang}\n{code}```", context_id, block_idx)

 def _is_likely_lang_tag(self, tag: str) -> bool:
  return bool(re.match(r'^[a-zA-Z0-9+#-]+$', tag)) and len(tag) < 15

 def detect_language(self, code: str) -> str:
  if "def " in code or "import " in code:
   return "python"
  if "{" in code and '"' in code and ":" in code:
   return "json"
  if "$" in code and ("{" in code or "if" in code):
   return "powershell"
  return ""

 def clear_cache(self) -> None:
  self._editor_cache.clear()

# Global instance
_renderer: Optional[MarkdownRenderer] = None

def get_renderer() -> MarkdownRenderer:
 global _renderer
 if _renderer is None: _renderer = MarkdownRenderer()
 return _renderer

def render(text: str, context_id: str = "default") -> None:
 """
 [C: src/theme_2.py:render_post_fx, tests/test_theme_nerv_alert.py:test_alert_pulsing_render_active, tests/test_theme_nerv_alert.py:test_alert_pulsing_render_inactive, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_alert_pulsing_render, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_crt_filter_disabled, tests/test_theme_nerv_fx.py:TestThemeNervFx.test_crt_filter_render]
 """
 get_renderer().render(text, context_id)

def render_unindented(text: str) -> None:
 get_renderer().render_unindented(text)

def render_code(code: str, lang: str = "", context_id: str = "default", block_idx: int = 0) -> None:
 get_renderer().render_code(code, lang, context_id, block_idx)
