"""Publish Notion contract pages through browser-harness.

This file is executed by `browser-harness`, not plain Python. The harness
preloads helpers such as goto_url, js, click_at_xy, wait, page_info, and
capture_screenshot into this script's globals.
"""

import json
import os
import re
import time
import traceback
import urllib.error
import urllib.request
from pathlib import Path


DOMAIN = os.environ.get("NOTION_PUBLISH_DOMAIN", "contracts.sellable.dev")
CONTEXT_HINT = os.environ.get("NOTION_PUBLISH_CONTEXT_HINT", "Sellable Admin")
AUTH_CHECK_ONLY = os.environ.get("NOTION_PUBLISH_AUTH_CHECK_ONLY") in {
    "1",
    "true",
    "True",
}
AUTH_CHECK_URL = os.environ.get("NOTION_PUBLISH_AUTH_CHECK_URL", "https://www.notion.so")
VERIFY_PUBLIC = os.environ.get("NOTION_PUBLISH_VERIFY_PUBLIC", "1") not in {
    "0",
    "false",
    "False",
}
LOG_DIR = Path(
    os.environ.get(
        "NOTION_PUBLISH_LOG_DIR",
        os.path.expanduser("~/.sellable-admin/logs/notion-publish"),
    )
)


def read_pages():
    raw = os.environ.get("NOTION_PUBLISH_PAGES_JSON")
    if not raw:
        raise RuntimeError("Missing NOTION_PUBLISH_PAGES_JSON")
    pages = json.loads(raw)
    if not isinstance(pages, list) or not pages:
        raise RuntimeError("NOTION_PUBLISH_PAGES_JSON must be a non-empty array")
    normalized = []
    for page in pages:
        notion_url = page.get("notionUrl") or page.get("notion")
        if not notion_url:
            raise RuntimeError(f"Page is missing notionUrl: {page!r}")
        normalized.append(
            {
                "name": page.get("name") or notion_url,
                "notionUrl": notion_url,
                "publicUrl": page.get("publicUrl"),
            }
        )
    return normalized


def text_now():
    return js("document.body ? document.body.innerText : ''") or ""


def dialog_text():
    return (
        js(
            """
            (() => [...document.querySelectorAll('[role="dialog"]')]
              .map((e) => (e.innerText || '').trim().replace(/\\s+/g, ' '))
              .join('\\n---DIALOG---\\n'))()
            """
        )
        or ""
    )


def visible_candidates(text, *, exact=True, selector=None):
    selector = selector or (
        'button,[role="button"],[role="tab"],[role="menuitem"],'
        '[role="option"],div,a'
    )
    return js(
        f"""
        (() => {{
          const needle = {json.dumps(text)};
          const exact = {json.dumps(exact)};
          const selector = {json.dumps(selector)};
          const els = [...document.querySelectorAll(selector)];
          return els.map((el, i) => {{
            const r = el.getBoundingClientRect();
            const attrText = [
              el.getAttribute('aria-label'),
              el.getAttribute('title'),
              el.getAttribute('data-tooltip'),
              el.getAttribute('data-content')
            ].filter(Boolean).join(' ');
            const txt = (el.innerText || el.textContent || attrText || '').trim().replace(/\\s+/g, ' ');
            const style = getComputedStyle(el);
            return {{
              i,
              tag: el.tagName,
              role: el.getAttribute('role'),
              text: txt,
              x: r.x,
              y: r.y,
              w: r.width,
              h: r.height,
              visible: r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
            }};
          }}).filter((item) => {{
            if (!item.visible || !item.text) return false;
            return exact ? item.text === needle : item.text.includes(needle);
          }});
        }})()
        """
    ) or []


def reveal_notion_topbar():
    try:
        info = page_info()
        for x, y in (
            (info.get("w", 1200) - 80, 24),
            (info.get("w", 1200) - 180, 24),
            (info.get("w", 1200) - 320, 24),
            (int(info.get("w", 1200) / 2), 24),
        ):
            cdp("Input.dispatchMouseEvent", type="mouseMoved", x=x, y=y)
            wait(0.15)
    except Exception:
        pass
    js(
        """
        (() => {
          window.scrollTo(0, 0);
          const points = [
            [window.innerWidth - 80, 24],
            [window.innerWidth - 180, 24],
            [window.innerWidth - 300, 24],
            [Math.floor(window.innerWidth / 2), 24],
          ];
          for (const [x, y] of points) {
            const target = document.elementFromPoint(x, y) || document.body;
            for (const type of ['mousemove', 'mouseover', 'mouseenter']) {
              target.dispatchEvent(new MouseEvent(type, {
                bubbles: true,
                cancelable: true,
                clientX: x,
                clientY: y,
                view: window
              }));
              document.dispatchEvent(new MouseEvent(type, {
                bubbles: true,
                cancelable: true,
                clientX: x,
                clientY: y,
                view: window
              }));
            }
          }
        })()
        """
    )
    wait(0.5)


def click_candidate(candidate):
    click_at_xy(candidate["x"] + candidate["w"] / 2, candidate["y"] + candidate["h"] / 2)


def click_text(
    text,
    *,
    exact=True,
    role=None,
    min_y=None,
    max_y=None,
    min_x=None,
    max_x=None,
    prefer="first",
    wait_after=0.7,
):
    candidates = visible_candidates(text, exact=exact)
    if role:
        candidates = [c for c in candidates if c.get("role") == role]
    if min_y is not None:
        candidates = [c for c in candidates if c["y"] >= min_y]
    if max_y is not None:
        candidates = [c for c in candidates if c["y"] <= max_y]
    if min_x is not None:
        candidates = [c for c in candidates if c["x"] >= min_x]
    if max_x is not None:
        candidates = [c for c in candidates if c["x"] <= max_x]
    candidates.sort(key=lambda c: (c["y"], c["x"], c["w"] * c["h"]))
    if prefer == "last":
        candidates = list(reversed(candidates))
    if not candidates:
        return False
    click_candidate(candidates[0])
    wait(wait_after)
    return True


def dismiss_prompt_if_present():
    for label in ("Dismiss", "Not now", "Cancel"):
        click_text(label, role="button", wait_after=0.2)


def notion_auth_status(body, current_url):
    auth_needles = [
        "Log in to your Notion account",
        "Verification code",
        "You’re almost there!",
        "You're almost there!",
        "Sign in to see this page",
        "or continue with",
        "Continue with Google",
    ]
    if any(needle in body for needle in auth_needles):
        return {
            "authenticated": False,
            "reason": "notion_auth_required",
            "detail": "Dedicated publisher profile is not signed into Notion.",
        }
    if (
        "notion.com" in current_url
        and "Get Notion free" in body
        and "Request a demo" in body
    ):
        return {
            "authenticated": False,
            "reason": "notion_marketing_site",
            "detail": "Dedicated publisher profile is on the public Notion marketing site.",
        }
    if (
        "This page couldn" in body
        and "You may not have access" in body
        and "Share" not in body
    ):
        return {
            "authenticated": False,
            "reason": "notion_page_inaccessible",
            "detail": "Internal Notion URL is inaccessible from this publisher profile.",
        }
    if "No access to this page" in body:
        return {
            "authenticated": False,
            "reason": "notion_no_access",
            "detail": "Publisher profile is signed in without access to this page.",
        }
    if CONTEXT_HINT in body:
        return {
            "authenticated": True,
            "reason": "context_hint_seen",
            "detail": f"Found context hint: {CONTEXT_HINT}",
        }
    if (
        "Sellable HQ" in body
        and "This workspace is managed by the Sellable MCP integration" in body
    ):
        return {
            "authenticated": True,
            "reason": "sellable_hq_content_seen",
            "detail": "Found private Sellable HQ page content.",
        }
    return {
        "authenticated": None,
        "reason": "unknown",
        "detail": "Could not prove Notion auth state from page text.",
    }


def check_auth_state():
    goto_url(AUTH_CHECK_URL)
    wait_for_load(20)
    wait(3)
    info = page_info()
    body = text_now()
    status = notion_auth_status(body, info.get("url", ""))
    status.update(
        {
            "url": info.get("url"),
            "title": info.get("title"),
            "contextHint": CONTEXT_HINT,
            "bodyPreview": body[:1000],
        }
    )
    return status


def choose_admin_notion_tab():
    """Prefer an already-authenticated admin Notion tab.

    Local Chrome can expose multiple profiles through one CDP server. Creating a
    new target may land in the wrong profile, so we first find an existing Notion
    target that can see the Sellable Admin workspace, then reuse that tab for all
    navigation.
    """
    for tab in list_tabs(include_chrome=True):
        url = tab.get("url", "")
        title = tab.get("title", "")
        if "notion.so" not in url and "Notion" not in title:
            continue
        try:
            switch_tab(tab)
            wait(0.6)
            body = text_now()
            info = page_info()
            auth_status = notion_auth_status(body, info.get("url", ""))
            if auth_status.get("authenticated") is True:
                return {
                    "selected": True,
                    "title": info.get("title"),
                    "url": info.get("url"),
                }
        except Exception:
            continue
    return {
        "selected": False,
        "title": page_info().get("title"),
        "url": page_info().get("url"),
    }


def wait_for_notion_page(timeout=25):
    deadline = time.time() + timeout
    while time.time() < deadline:
        info = page_info()
        current_url = info.get("url", "")
        body = text_now()
        auth_status = notion_auth_status(body, current_url)
        if auth_status["reason"] == "notion_auth_required":
            raise RuntimeError(
                "Notion authentication required. Log into the dedicated Notion browser profile, then rerun."
            )
        if auth_status["reason"] == "notion_marketing_site":
            raise RuntimeError(
                "Notion authentication required. Dedicated publisher profile is on the Notion marketing site; log into Notion in that isolated profile, then rerun."
            )
        if auth_status["reason"] == "notion_page_inaccessible":
            raise RuntimeError(
                "Notion authentication required or page is inaccessible. Log into the dedicated publisher profile and confirm it can open the internal Notion URL."
            )
        if auth_status["reason"] == "notion_no_access":
            raise RuntimeError("Current Notion browser profile has no access to this page")
        if CONTEXT_HINT in body and "Share" in body:
            return True
        wait(0.5)
    return False


def open_publish_dialog():
    dismiss_prompt_if_present()
    reveal_notion_topbar()
    body = text_now()
    if (
        f"This page is live on {DOMAIN}" in body
        or ("View site" in body and "Site settings" in body)
    ):
        return True
    if "Share Publish" not in dialog_text():
        if not click_text("Share", role="button", max_y=90, prefer="last"):
            raise RuntimeError("Could not find Notion Share button")
    deadline = time.time() + 8
    while time.time() < deadline:
        if "Share" in dialog_text() and "Publish" in dialog_text():
            return True
        wait(0.3)
    raise RuntimeError("Share dialog did not open")


def click_publish_tab():
    body = text_now()
    if (
        f"This page is live on {DOMAIN}" in body
        or ("View site" in body and "Site settings" in body)
    ):
        return True
    if not click_text("Publish", role="tab", max_y=140):
        # Fallback for Notion tabs that are divs rather than role=tab.
        if not click_text("Publish", max_y=140):
            raise RuntimeError("Could not find Publish tab")
    deadline = time.time() + 5
    while time.time() < deadline:
        dtext = dialog_text()
        if "Publish to web" in dtext or "Unpublish" in dtext:
            return True
        wait(0.3)
    return False


def publish_if_needed():
    dtext = dialog_text()
    body = text_now()
    if f"This page is live on" in body or ("View site" in body and "Site settings" in body):
        return "already_published"
    if "Unpublish" in dtext:
        return "already_published"
    if "Publish to web" not in dtext:
        raise RuntimeError("Publish panel did not render expected controls")
    if not click_text("Publish", role="button", min_y=120):
        raise RuntimeError("Could not find blue Publish button")
    deadline = time.time() + 12
    while time.time() < deadline:
        if "Unpublish" in dialog_text() or "This page is live on" in text_now():
            return "published"
        wait(0.5)
    raise RuntimeError("Notion did not confirm publish")


def current_domain_from_dialog():
    dtext = dialog_text()
    match = re.search(r"([a-z0-9.-]+\.(?:notion\.site|sellable\.dev))\s*/", dtext)
    return match.group(1) if match else None


def ensure_domain():
    if DOMAIN in dialog_text() or f"This page is live on {DOMAIN}" in text_now():
        return "already_domain"

    current = current_domain_from_dialog()
    if not current:
        raise RuntimeError(f"Could not detect current Notion site domain in dialog: {dialog_text()[:300]}")

    if not click_text(current, role="button", max_y=170):
        raise RuntimeError(f"Could not click site domain selector for {current}")

    deadline = time.time() + 5
    while time.time() < deadline:
        if DOMAIN in text_now():
            break
        wait(0.3)

    if not click_text(DOMAIN, role="menuitem", wait_after=1.5):
        # Fallback for menu items without a stable role.
        if not click_text(DOMAIN, wait_after=1.5):
            raise RuntimeError(f"Could not select {DOMAIN} in domain menu")

    deadline = time.time() + 10
    while time.time() < deadline:
        if DOMAIN in dialog_text() or f"This page is live on {DOMAIN}" in text_now():
            return f"changed_from_{current}"
        wait(0.5)
    raise RuntimeError(f"Domain did not switch to {DOMAIN}")


def verify_public_url(url, expected_name=None):
    if not url:
        return {"checked": False, "ok": None, "reason": "no publicUrl provided"}
    request = urllib.request.Request(
        url,
        headers={
            "User-Agent": (
                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148 Safari/537.36"
            )
        },
    )
    try:
        with urllib.request.urlopen(request, timeout=20) as response:
            body = response.read(100000).decode("utf-8", "replace")
            status = response.getcode()
    except urllib.error.HTTPError as error:
        body = error.read(2000).decode("utf-8", "replace")
        return {
            "checked": True,
            "ok": False,
            "status": error.code,
            "reason": body[:300],
        }
    except Exception as error:
        return {"checked": True, "ok": False, "reason": str(error)}

    needles = [DOMAIN, "notion", "Sellable"]
    if expected_name:
        needles.append(expected_name.split(" - ")[0])
    ok = status < 400 and any(needle.lower() in body.lower() for needle in needles)
    return {
        "checked": True,
        "ok": ok,
        "status": status,
        "titleSeen": "Sellable" in body or "Notion" in body,
    }


def publish_page(page):
    goto_url(page["notionUrl"])
    wait_for_load(20)
    wait(3)
    wait_for_notion_page()
    pre_public_check = (
        verify_public_url(page.get("publicUrl"), page.get("name"))
        if VERIFY_PUBLIC and page.get("publicUrl")
        else None
    )
    if pre_public_check and pre_public_check.get("ok"):
        return {
            "name": page["name"],
            "notionUrl": page["notionUrl"],
            "publicUrl": page.get("publicUrl"),
            "publishAction": "already_published",
            "domainAction": "public_url_verified",
            "liveBanner": f"This page is live on {DOMAIN}" in text_now(),
            "publicCheck": pre_public_check,
        }
    open_publish_dialog()
    click_publish_tab()
    publish_action = publish_if_needed()
    domain_action = ensure_domain()
    live = f"This page is live on {DOMAIN}" in text_now()
    public_check = (
        verify_public_url(page.get("publicUrl"), page.get("name"))
        if VERIFY_PUBLIC
        else {"checked": False, "ok": None, "reason": "disabled"}
    )
    return {
        "name": page["name"],
        "notionUrl": page["notionUrl"],
        "publicUrl": page.get("publicUrl"),
        "publishAction": publish_action,
        "domainAction": domain_action,
        "liveBanner": live,
        "publicCheck": public_check,
    }


def failure_result(page, error):
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "-", page.get("name", "page")).strip("-")
    screenshot = None
    try:
        screenshot = capture_screenshot(str(LOG_DIR / f"{safe_name or 'page'}-failure.png"), max_dim=1800)
    except Exception:
        pass
    try:
        info = page_info()
    except Exception as info_error:
        info = {"error": str(info_error)}
    try:
        body_preview = text_now()[:1000]
    except Exception as body_error:
        body_preview = f"<unavailable: {body_error}>"
    try:
        dialog_preview = dialog_text()[:1000]
    except Exception as dialog_error:
        dialog_preview = f"<unavailable: {dialog_error}>"
    return {
        "name": page.get("name"),
        "notionUrl": page.get("notionUrl"),
        "publicUrl": page.get("publicUrl"),
        "error": str(error),
        "traceback": traceback.format_exc(limit=3),
        "screenshot": screenshot,
        "pageInfo": info,
        "bodyPreview": body_preview,
        "dialogPreview": dialog_preview,
    }


def main():
    context = choose_admin_notion_tab()
    if AUTH_CHECK_ONLY:
        auth_check = check_auth_state()
        payload = {"domain": DOMAIN, "context": context, "authCheck": auth_check, "results": []}
        print("NOTION_PUBLISH_RESULTS_JSON=" + json.dumps(payload, ensure_ascii=True))
        return

    pages = read_pages()
    results = []
    for page in pages:
        try:
            result = publish_page(page)
            results.append(result)
            print(json.dumps({"event": "published", **result}, ensure_ascii=True), flush=True)
        except Exception as error:
            result = failure_result(page, error)
            results.append(result)
            print(json.dumps({"event": "failed", **result}, ensure_ascii=True), flush=True)
    payload = {"domain": DOMAIN, "context": context, "results": results}
    print("NOTION_PUBLISH_RESULTS_JSON=" + json.dumps(payload, ensure_ascii=True))


main()
