"""
Scratchpad UI: MarkdownTextStorage, ScratchpadTextView, ScratchpadScrollView, and sync/save/table helpers.
Used by app_pyobjc2 delegate; does not import app_pyobjc2. Delegate must implement saveChatsDb_().
"""
import os
import re
import json
from datetime import datetime

import objc
from Foundation import NSMutableAttributedString, NSMakeRange
from AppKit import (
    NSScrollView,
    NSTextView,
    NSTextFieldCell,
    NSTextStorage,
    NSFont,
    NSColor,
    NSMakeRect,
    NSTimer,
)
try:
    from AppKit import NSTorageEditedAttributes, NSTorageEditedCharacters
except ImportError:
    NSTorageEditedCharacters = 1
    NSTorageEditedAttributes = 2
try:
    from AppKit import NSLinkAttributeName, NSForegroundColorAttributeName, NSFontAttributeName
except ImportError:
    NSLinkAttributeName = "NSLink"
    NSForegroundColorAttributeName = "NSColor"
    NSFontAttributeName = "NSFont"

# Logging (no dependency on app_pyobjc2)
_SCRATCHPAD_LOG_DIR = os.path.join(os.path.expanduser("~"), ".mugi", "logs")
_SCRATCHPAD_DEBUG_LOG = os.path.join(_SCRATCHPAD_LOG_DIR, "scratchpad_debug.log")
_SCRATCHPAD_PERF_LOG_PATH = os.path.join(_SCRATCHPAD_LOG_DIR, "scratchpad_perf.log")
DEBUG_SCRATCHPAD = False


def _log_ts():
    """Return current timestamp for log lines."""
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]


def _scratchpad_log(msg, **data):
    try:
        os.makedirs(_SCRATCHPAD_LOG_DIR, exist_ok=True)
        line = json.dumps({"t": int(datetime.now().timestamp() * 1000), "msg": msg, **data}) + "\n"
        with open(_SCRATCHPAD_DEBUG_LOG, "a", encoding="utf-8") as f:
            f.write(line)
    except Exception:
        pass


def _scratchpad_perf_log(msg, **kwargs):
    try:
        os.makedirs(_SCRATCHPAD_LOG_DIR, exist_ok=True)
        parts = [f"{_log_ts()} {msg}"]
        for k, v in kwargs.items():
            parts.append(f" {k}={v}")
        with open(_SCRATCHPAD_PERF_LOG_PATH, "a", encoding="utf-8") as f:
            f.write("".join(parts) + "\n")
    except Exception:
        pass


def _scratchpad_debug(msg, **kwargs):
    if not DEBUG_SCRATCHPAD:
        return
    parts = [f"[Scratchpad] {msg}"]
    for k, v in kwargs.items():
        parts.append(f"  {k}={v!r}" if len(repr(v)) < 80 else f"  {k}=<len={getattr(v, '__len__', lambda: 0)()}>")
    print(" ".join(parts), flush=True)


# Left inset (pt) for scratchpad title field
SCRATCHPAD_TITLE_LEFT_INSET = 10.0


class InsetTextFieldCell(NSTextFieldCell):
    """NSTextFieldCell that draws and edits text with a left inset so title aligns with content below."""

    def _insetBounds_(self, bounds):
        try:
            x = bounds.origin.x + SCRATCHPAD_TITLE_LEFT_INSET
            y = bounds.origin.y
            w = max(0, bounds.size.width - SCRATCHPAD_TITLE_LEFT_INSET)
            h = bounds.size.height
            return NSMakeRect(x, y, w, h)
        except Exception:
            return bounds

    def titleRectForBounds_(self, bounds):
        return self._insetBounds_(bounds)

    def drawingRectForBounds_(self, bounds):
        return self._insetBounds_(bounds)

    def editWithFrame_inView_editor_delegate_event_(self, frame, view, editor, delegate, event):
        objc.super(InsetTextFieldCell, self).editWithFrame_inView_editor_delegate_event_(
            self._insetBounds_(frame), view, editor, delegate, event
        )

    def selectWithFrame_inView_editor_delegate_start_length_(self, frame, view, editor, delegate, start, length):
        objc.super(InsetTextFieldCell, self).selectWithFrame_inView_editor_delegate_start_length_(
            self._insetBounds_(frame), view, editor, delegate, start, length
        )


class ScratchpadScrollView(NSScrollView):
    """NSScrollView that ensures the document view becomes first responder on click and accepts first mouse."""

    def acceptsFirstMouse_(self, event):
        return True

    def mouseDown_(self, event):
        win = self.window()
        if win is not None:
            doc = self.documentView()
            if doc is not None:
                win.makeFirstResponder_(doc)
        return objc.super(ScratchpadScrollView, self).mouseDown_(event)


class ScratchpadTextView(NSTextView):
    """NSTextView that accepts first mouse so one click focuses it and arrow keys / cursor navigation work."""

    def acceptsFirstMouse_(self, event):
        return True

    def mouseDown_(self, event):
        win = self.window()
        if win is not None:
            win.makeFirstResponder_(self)
        return objc.super(ScratchpadTextView, self).mouseDown_(event)


class MarkdownTextStorage(NSTextStorage):
    """NSTextStorage subclass that applies inline Markdown formatting (headers, bold, italic, links) in real time."""

    def init(self):
        self = objc.super(MarkdownTextStorage, self).init()
        if self is None:
            return None
        self._backing = NSMutableAttributedString.alloc().init()
        self._defer_timer = None
        return self

    def string(self):
        return self._backing.string()

    def length(self):
        return self._backing.length()

    def replaceCharactersInRange_withString_(self, range, aString):
        self._backing.replaceCharactersInRange_withString_(range, aString)
        try:
            new_len = aString.length()
        except Exception:
            new_len = len(aString) if isinstance(aString, str) else len(str(aString))
        lengthDelta = new_len - range.length
        self.edited_range_changeInLength_(NSTorageEditedCharacters, range, lengthDelta)

    def setAttributes_range_(self, attrs, range):
        if attrs is None:
            attrs = {}
        self._backing.setAttributes_range_(attrs, range)
        self.edited_range_changeInLength_(NSTorageEditedAttributes, range, 0)

    def attributesAtIndex_effectiveRange_(self, index, rangePtr):
        return self._backing.attributesAtIndex_effectiveRange_(index, rangePtr)

    def processEditing(self):
        objc.super(MarkdownTextStorage, self).processEditing()
        if getattr(self, "_applying_attrs", False):
            self._applying_attrs = False
            _scratchpad_perf_log("processEditing", skipped="applying_attrs")
            return
        try:
            n = self.length()
            _scratchpad_perf_log("processEditing", len=n)
        except Exception:
            n = 0
        old = getattr(self, "_defer_timer", None)
        if old is not None:
            try:
                old.invalidate()
            except Exception:
                pass
            self._defer_timer = None
        t = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
            0.0, self, "_deferredApplyMarkdownAttributes:", None, False
        )
        self._defer_timer = t

    def _deferredApplyMarkdownAttributes_(self, timer):
        self._defer_timer = None
        try:
            n = self.length()
            _scratchpad_perf_log("deferred_apply", len=n)
            self._applyMarkdownAttributes()
        except Exception as e:
            _scratchpad_perf_log("deferred_apply_error", error=str(e)[:200])

    def _applyMarkdownAttributes(self):
        import time as _time
        t0 = _time.perf_counter()
        try:
            s = self.string()
            if s is None:
                _scratchpad_perf_log("apply_attrs", event="skip", reason="no_string")
                return
            raw = s if isinstance(s, str) else (s.utf8String() or str(s))
            n = len(raw)
            if n == 0:
                _scratchpad_perf_log("apply_attrs", event="skip", reason="empty", elapsed_ms=0)
                return
            _scratchpad_perf_log("apply_attrs_start", len=n)
        except Exception as e:
            _scratchpad_perf_log("apply_attrs", event="error", reason=str(e)[:80])
            return
        _scratchpad_font_size = 14.0
        try:
            base_font = NSFont.systemFontOfSize_(_scratchpad_font_size)
            base_color = NSColor.labelColor()
            dim_color = NSColor.tertiaryLabelColor()
            bold_font = NSFont.boldSystemFontOfSize_(_scratchpad_font_size)
            italic_font = NSFont.italicSystemFontOfSize_(_scratchpad_font_size)
            link_color = NSColor.linkColor()
        except Exception:
            base_font = NSFont.systemFontOfSize_(_scratchpad_font_size)
            base_color = NSColor.controlTextColor()
            try:
                dim_color = NSColor.secondaryLabelColor()
            except Exception:
                try:
                    dim_color = NSColor.tertiaryLabelColor()
                except Exception:
                    dim_color = NSColor.grayColor()
            bold_font = NSFont.boldSystemFontOfSize_(_scratchpad_font_size)
            italic_font = NSFont.systemFontOfSize_(_scratchpad_font_size)
            try:
                link_color = NSColor.linkColor()
            except Exception:
                link_color = NSColor.blueColor()
        full_range = NSMakeRange(0, n)
        self._backing.setAttributes_range_({}, full_range)
        self._backing.addAttribute_value_range_(NSFontAttributeName, base_font, full_range)
        self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, base_color, full_range)
        for m in re.finditer(r"^(#{1,6})\s+(.+)$", raw, re.MULTILINE):
            prefix, rest = m.group(1), m.group(2)
            line_start = m.start(0)
            line_len = m.end(0) - line_start
            if line_start + line_len > n:
                continue
            try:
                level = min(len(prefix), 6)
                header_size = _scratchpad_font_size * (1.85 - 0.15 * level)
                header_font = NSFont.boldSystemFontOfSize_(header_size)
                r = NSMakeRange(line_start, line_len)
                self._backing.addAttribute_value_range_(NSFontAttributeName, header_font, r)
                prefix_range = NSMakeRange(line_start, len(prefix))
                self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, dim_color, prefix_range)
            except Exception:
                pass
        try:
            hide_font = NSFont.systemFontOfSize_(0.1)
        except Exception:
            hide_font = base_font
        for m in re.finditer(r"\[([^\]]*)\]\((.*)\)", raw):
            full_start, full_end = m.start(0), m.end(0)
            text_start, text_end = m.start(1), m.end(1)
            url_start, url_end = m.start(2), m.end(2)
            if full_end > n:
                continue
            try:
                link_url = m.group(2)
                text_range = NSMakeRange(text_start, text_end - text_start)
                self._backing.addAttribute_value_range_(NSLinkAttributeName, link_url if link_url else m.group(1), text_range)
                self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, link_color, text_range)
                for start, length in [(full_start, 1), (text_end, 1)]:
                    if start >= 0 and start + length <= n:
                        self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, dim_color, NSMakeRange(start, length))
                paren_url_len = url_end - (url_start - 1) + 1
                if url_start >= 1 and url_start - 1 + paren_url_len <= n:
                    hide_range = NSMakeRange(url_start - 1, paren_url_len)
                    self._backing.addAttribute_value_range_(NSFontAttributeName, hide_font, hide_range)
                    try:
                        self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, NSColor.clearColor(), hide_range)
                    except Exception:
                        pass
            except Exception:
                pass
        for m in re.finditer(r"\*\*(.+?)\*\*|__(.+?)__", raw):
            inner = m.group(1) if m.group(1) is not None else m.group(2)
            start = m.start(0) + 2
            length = len(inner)
            if start + length > n:
                continue
            try:
                r = NSMakeRange(start, length)
                self._backing.addAttribute_value_range_(NSFontAttributeName, bold_font, r)
                for delim in (NSMakeRange(m.start(0), 2), NSMakeRange(m.end(0) - 2, 2)):
                    if delim.location + delim.length <= n:
                        self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, dim_color, delim)
            except Exception:
                pass
        for m in re.finditer(r"(?<!\*)\*([^*\n]+)\*(?!\*)", raw):
            start = m.start(1)
            length = m.end(1) - m.start(1)
            if start + length > n:
                continue
            try:
                r = NSMakeRange(start, length)
                self._backing.addAttribute_value_range_(NSFontAttributeName, italic_font, r)
                self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, dim_color, NSMakeRange(m.start(0), 1))
                self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, dim_color, NSMakeRange(m.end(0) - 1, 1))
            except Exception:
                pass
        for m in re.finditer(r"(?<!_)_((?:[^_\n]|_(?=[^_\n]*_))+)_(?!_)", raw):
            start = m.start(1)
            length = m.end(1) - m.start(1)
            if start + length > n:
                continue
            try:
                r = NSMakeRange(start, length)
                self._backing.addAttribute_value_range_(NSFontAttributeName, italic_font, r)
                self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, dim_color, NSMakeRange(m.start(0), 1))
                self._backing.addAttribute_value_range_(NSForegroundColorAttributeName, dim_color, NSMakeRange(m.end(0) - 1, 1))
            except Exception:
                pass
        self._applying_attrs = True
        self.edited_range_changeInLength_(NSTorageEditedAttributes, full_range, 0)
        elapsed_ms = (_time.perf_counter() - t0) * 1000
        _scratchpad_perf_log("apply_attrs_done", len=n, elapsed_ms=round(elapsed_ms, 2))
        return


def _sp_body(sp):
    """Return scratchpad body text (Joplin-style)."""
    if not sp or not isinstance(sp, dict):
        return ""
    return (sp.get("body") or "") or ""


def _markdown_to_html(text):
    """Convert markdown to HTML for scratchpad preview."""
    if not text or not text.strip():
        return "<p></p>"
    try:
        import markdown
        html = markdown.markdown(text, extensions=["extra", "nl2br"])
        return html if html else "<p></p>"
    except Exception:
        pass
    import html as html_module
    escaped = html_module.escape(text)
    return f"<pre style='white-space:pre-wrap;font-family:system-ui;font-size:12px;'>{escaped}</pre>"


def _view_is_descendant_of_(view, ancestor):
    """Return True if view is a subview of ancestor."""
    if view is None or ancestor is None:
        return False
    if view == ancestor:
        return True
    try:
        v = view
        for _ in range(100):
            v = v.superview()
            if v is None:
                return False
            if v == ancestor:
                return True
    except Exception:
        pass
    return False


def _editor_text_view_string(tv):
    """Get plain text from an NSTextView."""
    if tv is None:
        _scratchpad_log("_editor_text_view_string", step="tv_is_None", out_len=0)
        return ""
    def _to_str(raw):
        if raw is None:
            return ""
        if isinstance(raw, str):
            return raw
        try:
            s = raw.utf8String()
            if s is not None:
                return s
        except Exception as e:
            _scratchpad_log("_to_str", method="utf8String", error=str(e)[:100])
            pass
        try:
            s = str(raw)
            if s and s != repr(raw):
                return s
        except Exception as e:
            _scratchpad_log("_to_str", method="str", error=str(e)[:100])
            pass
        return ""
    out = ""
    source = ""
    try:
        ts = tv.textStorage()
        _scratchpad_log("_editor_text_view_string", step="textStorage", ts_is_None=ts is None, ts_id=id(ts) if ts else None)
        if ts is not None:
            raw = ts.string()
            _scratchpad_log("_editor_text_view_string", step="ts.string", raw_type=type(raw).__name__, raw_id=id(raw) if raw else None, raw_len=getattr(raw, "length", None))
            out = _to_str(raw)
            if out:
                source = "textStorage"
    except Exception as e:
        _scratchpad_log("_editor_text_view_string", step="textStorage", error=str(e)[:150])
    if not out:
        try:
            raw = tv.string()
            _scratchpad_log("_editor_text_view_string", step="tv.string", raw_type=type(raw).__name__, raw_len=getattr(raw, "length", None))
            out = _to_str(raw)
            if out:
                source = "string"
        except Exception as e:
            _scratchpad_log("_editor_text_view_string", step="tv.string", error=str(e)[:150])
    if not out:
        try:
            att = tv.attributedString()
            if att is not None:
                raw = att.string()
                out = _to_str(raw)
                if out:
                    source = "attributedString"
        except Exception as e:
            _scratchpad_log("_editor_text_view_string", step="attributedString", error=str(e)[:150])
    _scratchpad_log("_editor_text_view_string", step="result", source=source, out_len=len(out), preview=(out[:120] if out else ""))
    return out


def _sync_editor_scratchpad_content(delegate, editor_win, tab_id, sp_id, tv):
    """Sync editor window NSTextView content to the tab's scratchpad and save."""
    text = _editor_text_view_string(tv)
    tab_id = str(tab_id)
    sp_id = str(sp_id)
    tab = next((t for t in delegate._tabs if str(t.get("tab_id")) == tab_id), None)
    if tab is None:
        return
    for sp in (tab.get("scratchpads") or []):
        if str(sp.get("id")) == sp_id:
            sp["body"] = text
            _scratchpad_log("sync_editor_wrote", sp_id=str(sp_id)[:12], len=len(text))
            try:
                from chat_storage import save_scratchpad
                save_scratchpad(sp_id, tab_id, sp.get("title") or "Untitled", text)
            except Exception as e:
                print(f"{_log_ts()} [WARN] chat_storage.save_scratchpad failed: {e}", flush=True)
            delegate.saveChatsDb_(None)
            for wid in getattr(delegate, "_window_scratchpad_content_scroll", None) or {}:
                if delegate._window_to_tab_id.get(wid) != tab_id:
                    continue
                if str(delegate._window_current_scratchpad_id.get(wid)) != sp_id:
                    continue
                try:
                    delegate._window_scratchpad_pending_content[wid] = text
                except Exception:
                    pass
            break


def _save_scratchpad_editor_window(delegate, win):
    """Save scratchpad editor window content to the tab's scratchpad. Call when close is initiated."""
    editor_info = delegate._scratchpad_editor_windows.get(win)
    if editor_info is None:
        if DEBUG_SCRATCHPAD:
            _scratchpad_debug("_save_scratchpad_editor_window no editor_info for win", win=id(win) if win else None)
        return
    tab_id, sp_id, tv = editor_info
    try:
        text = _editor_text_view_string(tv)
        if not text and win is not None:
            try:
                cv = win.contentView()
                if cv is not None and cv.subviews() and len(cv.subviews()) > 0:
                    scroll = cv.subviews()[0]
                    if scroll is not None and scroll.documentView() is not None:
                        live_tv = scroll.documentView()
                        if live_tv is not tv:
                            text = _editor_text_view_string(live_tv)
            except Exception:
                pass
        tab_id = str(tab_id)
        sp_id = str(sp_id)
        if DEBUG_SCRATCHPAD:
            _scratchpad_debug("_save_scratchpad_editor_window", tab_id=tab_id, sp_id=sp_id, text_len=len(text))
        tab = next((t for t in delegate._tabs if str(t.get("tab_id")) == tab_id), None)
        if tab is None:
            if DEBUG_SCRATCHPAD:
                _scratchpad_debug("_save_scratchpad_editor_window tab not found", tab_id=tab_id)
            return
        scratchpads = tab.get("scratchpads") or []
        found = False
        for sp in scratchpads:
            if str(sp.get("id")) == sp_id:
                sp["body"] = text
                found = True
                _scratchpad_log("editor_window_saved", sp_id=str(sp_id)[:12], body_len=len(text))
                try:
                    from chat_storage import save_scratchpad
                    save_scratchpad(sp_id, tab_id, sp.get("title") or "Untitled", text)
                except Exception as e:
                    print(f"{_log_ts()} [WARN] chat_storage.save_scratchpad failed: {e}", flush=True)
                break
        if DEBUG_SCRATCHPAD:
            _scratchpad_debug("_save_scratchpad_editor_window result", sp_found=found)
        if found:
            delegate.saveChatsDb_(None)
            for wid in getattr(delegate, "_window_scratchpad_content_scroll", None) or {}:
                if delegate._window_to_tab_id.get(wid) != tab_id:
                    continue
                if str(delegate._window_current_scratchpad_id.get(wid)) != str(sp_id):
                    continue
                try:
                    delegate._window_scratchpad_pending_content[wid] = text
                except Exception:
                    pass
    except Exception as e:
        print(f"{_log_ts()} [WARN] Scratchpad save error: {e}", flush=True)


def _scratchpad_id_match(stored_id, payload_id):
    """Return True if stored_id matches payload_id. Handles str/UUID and 36 vs 37 char formats."""
    if not payload_id:
        return False
    a = str(stored_id or "").strip()
    b = str(payload_id or "").strip()
    if a == b:
        return True
    if len(a) >= 36 and len(b) >= 36 and a[:36] == b[:36]:
        return True
    return False


def _history_wid_for_table(delegate, table):
    """Return the window id (wid) for this history table, or None."""
    if table is None:
        return None
    m = getattr(delegate, "_table_to_window", None) or {}
    wid = m.get(table)
    if wid is not None:
        return wid
    try:
        for t, w in list(m.items()):
            if t == table:
                return w
            if hasattr(t, "isEqual_") and callable(getattr(t, "isEqual_")):
                try:
                    if t.isEqual_(table):
                        return w
                except Exception:
                    pass
    except Exception:
        pass
    return None


def _scratchpad_wid_for_table(delegate, table):
    """Return the window id (wid) for this scratchpad table, or None."""
    if table is None:
        return None
    m = getattr(delegate, "_scratchpad_table_to_window", None) or {}
    wid = m.get(table)
    if wid is not None:
        return wid
    try:
        for t, w in list(m.items()):
            if t == table:
                return w
            if hasattr(t, "isEqual_") and callable(getattr(t, "isEqual_")):
                try:
                    if t.isEqual_(table):
                        return w
                except Exception:
                    pass
    except Exception:
        pass
    return None


def _wid_for_table_via_window(delegate, table):
    """Resolve window id from table by table.window() and matching _window_by_id (by windowNumber)."""
    if table is None:
        return None
    try:
        win = table.window()
        if win is None:
            return None
        num = win.windowNumber()
    except Exception:
        return None
    by_id = getattr(delegate, "_window_by_id", None) or {}
    for wid, w in list(by_id.items()):
        try:
            if w is not None and w.windowNumber() == num:
                return wid
        except Exception:
            pass
    return None


def _sync_right_pane_subview_visibility(delegate, wid):
    """Show scratchpad, terminal, or live view subview in the right pane container based on _window_right_pane_mode."""
    if wid is None:
        return
    mode = (getattr(delegate, "_window_right_pane_mode", None) or {}).get(wid) or "scratchpad"
    sp_pane = (getattr(delegate, "_window_scratchpad_pane", None) or {}).get(wid)
    term_pane = (getattr(delegate, "_window_terminal_pane", None) or {}).get(wid)
    lv_pane = (getattr(delegate, "_window_liveview_pane", None) or {}).get(wid)
    if sp_pane is not None:
        try:
            sp_pane.setHidden_(mode != "scratchpad")
        except Exception:
            pass
    if term_pane is not None:
        try:
            term_pane.setHidden_(mode != "terminal")
        except Exception:
            pass
    if lv_pane is not None:
        try:
            lv_pane.setHidden_(mode != "liveview")
        except Exception:
            pass
