"""Browser session manager: persistent tabs for iterative inspection/clicking."""

from __future__ import annotations

import asyncio
import hashlib
import logging
import time
from dataclasses import dataclass, field
from typing import Any

from nodriver.cdp.runtime import evaluate as cdp_evaluate

log = logging.getLogger("crawler.sessions")

SESSION_TTL = 600  # 10 min idle before cleanup


@dataclass
class BrowserSession:
    session_id: str
    tab: Any  # nodriver tab
    url: str
    final_url: str = ""
    title: str = ""
    created_at: float = 0.0
    last_used: float = 0.0
    snapshots: list[dict] = field(default_factory=list)


_sessions: dict[str, BrowserSession] = {}
_lock = asyncio.Lock()


async def _js_eval(tab, script: str, default=None):
    """Evaluate JavaScript in a tab, returning the value."""
    try:
        result = await tab.send(cdp_evaluate(script, await_promise=True, return_by_value=True))
        # tab.send returns (RemoteObject, None) tuple
        obj = result[0] if isinstance(result, tuple) else result
        return obj.value if hasattr(obj, 'value') else default
    except Exception as exc:
        log.debug("_js_eval failed: %s", exc)
        return default


async def create_session(tab, url: str, title: str = "") -> str:
    sid = hashlib.sha256(f"{url}:{time.time()}".encode()).hexdigest()[:16]
    now = time.time()
    async with _lock:
        _sessions[sid] = BrowserSession(
            session_id=sid, tab=tab, url=url, final_url=url,
            title=title, created_at=now, last_used=now,
        )
    log.info("Created browser session %s for %s", sid, url[:80])
    return sid


async def get_session(session_id: str) -> BrowserSession | None:
    async with _lock:
        sess = _sessions.get(session_id)
        if sess:
            sess.last_used = time.time()
    if sess is None:
        return None
    try:
        await _js_eval(sess.tab, "1")
    except Exception:
        async with _lock:
            _sessions.pop(session_id, None)
        return None
    return sess


async def close_session(session_id: str) -> bool:
    async with _lock:
        sess = _sessions.pop(session_id, None)
    if sess:
        try:
            from crawler import close_tab_safely
            await close_tab_safely(sess.tab, f"session {session_id}")
        except Exception as exc:
            log.debug("Error closing session tab: %s", exc)
        log.info("Closed browser session %s", session_id)
        return True
    return False


async def get_actions(session_id: str) -> list[dict]:
    sess = await get_session(session_id)
    if not sess:
        return []

    exclude_js = r"""
    (() => {
        const skip = ['cookie','login','logout','cart','checkout','account','register',
            'share','print','newsletter','language','search','close','menu'];
        for (const s of ['.cookie','.cookie-banner','.consent','.newsletter',
            '.login','.logout','.cart','[aria-label*="cookie"]','[aria-label*="login"]',
            '[aria-label*="language"]','[aria-label*="share"]','[aria-label*="print"]',
            '.social','.language-switcher']) {
            try { for (const el of document.querySelectorAll(s)) { el.setAttribute('data-crawler-skip','1'); } } catch(e){}
        }
        for (const btn of document.querySelectorAll('button, a, [role="button"], [role="link"]')) {
            const t = (btn.textContent || '').toLowerCase().trim();
            for (const w of skip) { if (t.includes(w)) { btn.setAttribute('data-crawler-skip','1'); break; } }
        }
    })()
    """

    discover_js = r"""
    (() => {
        const candidates = [];
        const selectors = [
            'a[href]:not([data-crawler-skip])',
            'button:not([data-crawler-skip])',
            '[role="button"]:not([data-crawler-skip])',
            '[role="link"]:not([data-crawler-skip])',
            '[onclick]:not([data-crawler-skip])',
            '.card:not([data-crawler-skip])',
            '.product-card:not([data-crawler-skip])',
            '.product-tile:not([data-crawler-skip])',
        ];
        let idx = 0;
        for (const sel of selectors) {
            try {
                for (const el of document.querySelectorAll(sel)) {
                    const rect = el.getBoundingClientRect();
                    if (rect.width < 10 || rect.height < 10) continue;
                    if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
                    if (el.getAttribute('data-crawler-skip') === '1') continue;
                    const text = (el.textContent || el.innerText || '').trim().slice(0, 150);
                    if (!text) continue;
                    const tag = el.tagName.toLowerCase();
                    const role = el.getAttribute('role') || '';
                    const aria = el.getAttribute('aria-label') || '';
                    const title = el.getAttribute('title') || '';
                    const href = el.getAttribute('href') || '';
                    const onclick = el.getAttribute('onclick') ? el.getAttribute('onclick').slice(0, 100) : '';
                    const dataHref = el.getAttribute('data-href') || el.getAttribute('data-url') || '';
                    const id = el.getAttribute('id') || '';
                    const cls = (el.className || '').toString().slice(0, 100);
                    const cx = Math.round(rect.x + rect.width/2);
                    const cy = Math.round(rect.y + rect.height/2);
                    const sig = [tag, text.slice(0,60), href.slice(0,100), '' + cx, '' + cy].join('|');
                    const actionId = 'act_' + idx.toString(36);
                    idx++;
                    candidates.push({
                        action_id: actionId,
                        node_id: 'n_' + idx.toString(36),
                        tag, role, aria_label: aria, title,
                        text, normalized_text: text.toLowerCase(),
                        href, onclick_hash: onclick ? onclick.split('').reduce((a,b)=>{a=((a<<5)-a)+b.charCodeAt(0);return a|0},0).toString(16) : '',
                        data_href: dataHref,
                        id, classes: cls,
                        cx, cy,
                        bbox: {x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height)},
                        visible: true, enabled: !el.disabled,
                        selector_candidates: [tag + (id?('#'+id):'') + (cls?('.'+cls.split(' ')[0]):''), tag + ':has-text(\'' + text.slice(0,40).replace(/'/g,"\\'") + '\')'],
                        action_signature: '*' + sig.split('').reduce((a,b)=>{a=((a<<5)-a)+b.charCodeAt(0);return a|0},0).toString(16),
                    });
                    if (idx >= 300) break;
                }
            } catch(e) {}
            if (idx >= 300) break;
        }
        return candidates.slice(0, 250);
    })()
    """

    try:
        await _js_eval(sess.tab, exclude_js)
        await asyncio.sleep(0.2)
        result = await _js_eval(sess.tab, discover_js)
        if isinstance(result, list):
            return result
    except Exception as exc:
        log.debug("get_actions failed: %s", exc)
    return []


_MODAL_CHECK = r"""
(() => {
    for (const m of document.querySelectorAll('[role="dialog"], .modal, .overlay, .lightbox, .popup, [aria-modal="true"]')) {
        const s = getComputedStyle(m);
        if (s.display !== 'none' && s.visibility !== 'hidden' && m.getBoundingClientRect().width > 50) {
            return {found: true, text: (m.textContent || '').trim().slice(0, 3000)};
        }
    }
    return {found: false};
})()
"""


async def click_action(session_id: str, action_id: str, timeout: float = 5.0) -> dict | None:
    sess = await get_session(session_id)
    if not sess:
        return None

    before_url = ""
    try:
        loc = await _js_eval(sess.tab, "() => window.location.href")
        before_url = str(loc) if loc else ""
    except Exception:
        pass

    before_text_len = 0
    try:
        before_text_len = await _js_eval(sess.tab,
            "(function() { return (document.body.textContent || '').trim().length; })()")
        before_text_len = int(before_text_len) if before_text_len else 0
    except Exception:
        pass

    # Click by finding the element via JS
    click_js = f"""
    (() => {{
        const candidates = document.querySelectorAll('a, button, [role="button"], [role="link"], [onclick], .card, .product-card');
        let idx = 0;
        for (const el of candidates) {{
            const tag = el.tagName.toLowerCase();
            const id = el.getAttribute('id') || '';
            const cls = (el.className || '').toString().slice(0,60);
            const text = (el.textContent || '').trim().slice(0,60);
            const rect = el.getBoundingClientRect();
            const cx = Math.round(rect.x + rect.width/2);
            const cy = Math.round(rect.y + rect.height/2);
            const aid = 'act_' + idx.toString(36);
            if (aid === '{action_id}') {{
                el.click();
                return {{ok:true, cx, cy}};
            }}
            idx++;
        }}
        return {{ok:false}};
    }})()
    """
    try:
        click_result = await _js_eval(sess.tab, click_js)
        if isinstance(click_result, dict) and click_result.get("ok"):
            pass
        else:
            return {"outcome": "failed", "before_url": before_url, "error": "element_not_found"}
    except Exception as exc:
        return {"outcome": "failed", "before_url": before_url, "error": str(exc)}

    await asyncio.sleep(min(timeout, 3.0))

    after_url = ""
    try:
        loc = await _js_eval(sess.tab, "() => window.location.href")
        after_url = str(loc) if loc else ""
    except Exception:
        pass

    after_text_len = 0
    try:
        after_text_len = await _js_eval(sess.tab,
            "(function() { return (document.body.textContent || '').trim().length; })()")
        after_text_len = int(after_text_len) if after_text_len else 0
    except Exception:
        pass

    # Detect outcome
    result: dict = {
        "before_url": before_url,
        "after_url": after_url,
        "before_text_len": before_text_len,
        "after_text_len": after_text_len,
    }

    if after_url and after_url.rstrip("/") != before_url.rstrip("/"):
        result["outcome"] = "url_changed"
        return result

    try:
        modal = await _js_eval(sess.tab, _MODAL_CHECK)
        if isinstance(modal, dict) and modal.get("found"):
            result["outcome"] = "modal_opened"
            result["modal_text"] = modal.get("text", "")
            return result
    except Exception:
        pass

    if abs(after_text_len - before_text_len) > 200:
        result["outcome"] = "content_changed"
        delta = after_text_len - before_text_len
        if delta > 500:
            result["added_rows"] = delta // 200
        return result

    result["outcome"] = "no_change"
    return result


_SIMPLIFIED_DOM_JS = r"""
(() => {
    const nodes = [];
    const skipTags = new Set(['script','style','noscript','svg','path','meta','link','br','hr']);
    const interesting = new Set(['h1','h2','h3','h4','h5','h6','a','button','input','select',
        'textarea','table','tr','td','th','ul','ol','li','div','section','article','nav',
        'header','footer','main','aside','p','span','img','form','label']);
    let idx = 0;
    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
        acceptNode: function(node) {
            if (skipTags.has(node.tagName.toLowerCase())) return NodeFilter.FILTER_REJECT;
            if (!interesting.has(node.tagName.toLowerCase())) return NodeFilter.FILTER_SKIP;
            const rect = node.getBoundingClientRect();
            if (rect.width < 1 && rect.height < 1) return NodeFilter.FILTER_SKIP;
            return NodeFilter.FILTER_ACCEPT;
        }
    });
    let node;
    const sectionHeadings = [];
    while (node = walker.nextNode()) {
        if (idx >= 500) break;
        const tag = node.tagName.toLowerCase();
        const text = (node.textContent || '').trim().slice(0, 200);
        const ownText = (node.childNodes.length === 1 && node.childNodes[0].nodeType === 3)
            ? node.textContent.trim().slice(0, 200) : '';
        const rect = node.getBoundingClientRect();
        const visible = rect.width > 5 && rect.height > 5;
        if (!visible && tag !== 'a' && tag !== 'button') continue;
        if (/^h[1-6]$/.test(tag)) sectionHeadings.push({level: parseInt(tag[1]), text: ownText || text});
        const id = node.getAttribute('id') || '';
        const role = node.getAttribute('role') || '';
        const href = node.getAttribute('href') || '';
        const aria = node.getAttribute('aria-label') || '';
        const cls = (node.className || '').toString().slice(0, 100);
        const currentSection = sectionHeadings.length ? sectionHeadings[sectionHeadings.length-1] : null;
        nodes.push({
            node_id: 'n_' + (idx++),
            tag, text, own_text: ownText,
            role, aria_label: aria, href, id, classes: cls,
            visible, enabled: !node.disabled,
            section_heading: currentSection ? currentSection.text : null,
            section_level: currentSection ? currentSection.level : null,
            bbox: {x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height)},
            selector_candidates: [
                tag + (id?('#'+id):''),
                tag + ':has-text(\'' + ownText.slice(0,40).replace(/'/g,"\\'") + '\')'
            ],
            child_count: node.children ? node.children.length : 0,
        });
    }
    const title = document.title || '';
    const visibleText = (document.body.textContent || '').trim().slice(0, 5000);
    return {title, visible_text_preview: visibleText, node_count: nodes.length, nodes};
})()
"""


async def get_dom(session_id: str, max_nodes: int = 300) -> dict | None:
    sess = await get_session(session_id)
    if not sess:
        return None
    try:
        result = await _js_eval(sess.tab, _SIMPLIFIED_DOM_JS)
        if isinstance(result, dict):
            nodes = result.get("nodes", [])
            result["nodes"] = nodes[:max_nodes]
            result["node_count"] = len(result["nodes"])
            return result
    except Exception as exc:
        log.debug("get_dom failed: %s", exc)
    return {"title": "", "visible_text_preview": "", "node_count": 0, "nodes": []}


async def cleanup_expired() -> int:
    now = time.time()
    expired = []
    async with _lock:
        for sid, sess in list(_sessions.items()):
            if now - sess.last_used > SESSION_TTL:
                expired.append(sid)
    count = 0
    for sid in expired:
        if await close_session(sid):
            count += 1
    return count
