# -*- coding: utf-8 -*-

"""
Adapted from https://github.com/xiilei/dwml/blob/master/dwml/latex_dict.py
On 25/03/2025
"""

from __future__ import unicode_literals

CHARS = ("{", "}", "_", "^", "#", "&", "$", "%", "~")

BLANK = ""
BACKSLASH = "\\"
ALN = "&"

CHR = {
    # Unicode : Latex Math Symbols
    # Top accents
    "\u0300": "\\grave{{{0}}}",
    "\u0301": "\\acute{{{0}}}",
    "\u0302": "\\hat{{{0}}}",
    "\u0303": "\\tilde{{{0}}}",
    "\u0304": "\\bar{{{0}}}",
    "\u0305": "\\overbar{{{0}}}",
    "\u0306": "\\breve{{{0}}}",
    "\u0307": "\\dot{{{0}}}",
    "\u0308": "\\ddot{{{0}}}",
    "\u0309": "\\ovhook{{{0}}}",
    "\u030a": "\\ocirc{{{0}}}}",
    "\u030c": "\\check{{{0}}}}",
    "\u0310": "\\candra{{{0}}}",
    "\u0312": "\\oturnedcomma{{{0}}}",
    "\u0315": "\\ocommatopright{{{0}}}",
    "\u031a": "\\droang{{{0}}}",
    "\u0338": "\\not{{{0}}}",
    "\u20d0": "\\leftharpoonaccent{{{0}}}",
    "\u20d1": "\\rightharpoonaccent{{{0}}}",
    "\u20d2": "\\vertoverlay{{{0}}}",
    "\u20d6": "\\overleftarrow{{{0}}}",
    "\u20d7": "\\vec{{{0}}}",
    "\u20db": "\\dddot{{{0}}}",
    "\u20dc": "\\ddddot{{{0}}}",
    "\u20e1": "\\overleftrightarrow{{{0}}}",
    "\u20e7": "\\annuity{{{0}}}",
    "\u20e9": "\\widebridgeabove{{{0}}}",
    "\u20f0": "\\asteraccent{{{0}}}",
    # Bottom accents
    "\u0330": "\\wideutilde{{{0}}}",
    "\u0331": "\\underbar{{{0}}}",
    "\u20e8": "\\threeunderdot{{{0}}}",
    "\u20ec": "\\underrightharpoondown{{{0}}}",
    "\u20ed": "\\underleftharpoondown{{{0}}}",
    "\u20ee": "\\underledtarrow{{{0}}}",
    "\u20ef": "\\underrightarrow{{{0}}}",
    # Over | group
    "\u23b4": "\\overbracket{{{0}}}",
    "\u23dc": "\\overparen{{{0}}}",
    "\u23de": "\\overbrace{{{0}}}",
    # Under| group
    "\u23b5": "\\underbracket{{{0}}}",
    "\u23dd": "\\underparen{{{0}}}",
    "\u23df": "\\underbrace{{{0}}}",
}

CHR_BO = {
    # Big operators,
    "\u2140": "\\Bbbsum",
    "\u220f": "\\prod",
    "\u2210": "\\coprod",
    "\u2211": "\\sum",
    "\u222b": "\\int",
    "\u22c0": "\\bigwedge",
    "\u22c1": "\\bigvee",
    "\u22c2": "\\bigcap",
    "\u22c3": "\\bigcup",
    "\u2a00": "\\bigodot",
    "\u2a01": "\\bigoplus",
    "\u2a02": "\\bigotimes",
}

T = {
    # Greek letters
    "\U0001d6fc": "\\alpha ",
    "\U0001d6fd": "\\beta ",
    "\U0001d6fe": "\\gamma ",
    "\U0001d6ff": "\\delta ",
    "\U0001d700": "\\epsilon ",
    "\U0001d701": "\\zeta ",
    "\U0001d702": "\\eta ",
    "\U0001d703": "\\theta ",
    "\U0001d704": "\\iota ",
    "\U0001d705": "\\kappa ",
    "\U0001d706": "\\lambda ",
    "\U0001d707": "\\mu ",
    "\U0001d708": "\\nu ",
    "\U0001d709": "\\xi ",
    "\U0001d70a": "\\omicron ",
    "\U0001d70b": "\\pi ",
    "\U0001d70c": "\\rho ",
    "\U0001d70d": "\\varsigma ",
    "\U0001d70e": "\\sigma ",
    "\U0001d70f": "\\tau ",
    "\U0001d710": "\\upsilon ",
    "\U0001d711": "\\phi ",
    "\U0001d712": "\\chi ",
    "\U0001d713": "\\psi ",
    "\U0001d714": "\\omega ",
    "\U0001d715": "\\partial ",
    "\U0001d716": "\\varepsilon ",
    "\U0001d717": "\\vartheta ",
    "\U0001d718": "\\varkappa ",
    "\U0001d719": "\\varphi ",
    "\U0001d71a": "\\varrho ",
    "\U0001d71b": "\\varpi ",
    # Relation symbols
    "\u2190": "\\leftarrow ",
    "\u2191": "\\uparrow ",
    "\u2192": "\\rightarrow ",
    "\u2193": "\\downarrow ",
    "\u2194": "\\leftrightarrow ",
    "\u2195": "\\updownarrow ",
    "\u2196": "\\nwarrow ",
    "\u2197": "\\nearrow ",
    "\u2198": "\\searrow ",
    "\u2199": "\\swarrow ",
    "\u22ee": "\\vdots ",
    "\u22ef": "\\cdots ",
    "\u22f0": "\\adots ",
    "\u22f1": "\\ddots ",
    "\u2260": "\\ne ",
    "\u2264": "\\leq ",
    "\u2265": "\\geq ",
    "\u2266": "\\leqq ",
    "\u2267": "\\geqq ",
    "\u2268": "\\lneqq ",
    "\u2269": "\\gneqq ",
    "\u226a": "\\ll ",
    "\u226b": "\\gg ",
    "\u2208": "\\in ",
    "\u2209": "\\notin ",
    "\u220b": "\\ni ",
    "\u220c": "\\nni ",
    # Ordinary symbols
    "\u221e": "\\infty ",
    # Binary relations
    "\u00b1": "\\pm ",
    "\u2213": "\\mp ",
    # Italic, Latin, uppercase
    "\U0001d434": "A",
    "\U0001d435": "B",
    "\U0001d436": "C",
    "\U0001d437": "D",
    "\U0001d438": "E",
    "\U0001d439": "F",
    "\U0001d43a": "G",
    "\U0001d43b": "H",
    "\U0001d43c": "I",
    "\U0001d43d": "J",
    "\U0001d43e": "K",
    "\U0001d43f": "L",
    "\U0001d440": "M",
    "\U0001d441": "N",
    "\U0001d442": "O",
    "\U0001d443": "P",
    "\U0001d444": "Q",
    "\U0001d445": "R",
    "\U0001d446": "S",
    "\U0001d447": "T",
    "\U0001d448": "U",
    "\U0001d449": "V",
    "\U0001d44a": "W",
    "\U0001d44b": "X",
    "\U0001d44c": "Y",
    "\U0001d44d": "Z",
    # Italic, Latin, lowercase
    "\U0001d44e": "a",
    "\U0001d44f": "b",
    "\U0001d450": "c",
    "\U0001d451": "d",
    "\U0001d452": "e",
    "\U0001d453": "f",
    "\U0001d454": "g",
    "\U0001d456": "i",
    "\U0001d457": "j",
    "\U0001d458": "k",
    "\U0001d459": "l",
    "\U0001d45a": "m",
    "\U0001d45b": "n",
    "\U0001d45c": "o",
    "\U0001d45d": "p",
    "\U0001d45e": "q",
    "\U0001d45f": "r",
    "\U0001d460": "s",
    "\U0001d461": "t",
    "\U0001d462": "u",
    "\U0001d463": "v",
    "\U0001d464": "w",
    "\U0001d465": "x",
    "\U0001d466": "y",
    "\U0001d467": "z",
}

FUNC = {
    "sin": "\\sin({fe})",
    "cos": "\\cos({fe})",
    "tan": "\\tan({fe})",
    "arcsin": "\\arcsin({fe})",
    "arccos": "\\arccos({fe})",
    "arctan": "\\arctan({fe})",
    "arccot": "\\arccot({fe})",
    "sinh": "\\sinh({fe})",
    "cosh": "\\cosh({fe})",
    "tanh": "\\tanh({fe})",
    "coth": "\\coth({fe})",
    "sec": "\\sec({fe})",
    "csc": "\\csc({fe})",
}

FUNC_PLACE = "{fe}"

BRK = "\\\\"

CHR_DEFAULT = {
    "ACC_VAL": "\\hat{{{0}}}",
}

POS = {
    "top": "\\overline{{{0}}}",  # not sure
    "bot": "\\underline{{{0}}}",
}

POS_DEFAULT = {
    "BAR_VAL": "\\overline{{{0}}}",
}

SUB = "_{{{0}}}"

SUP = "^{{{0}}}"

F = {
    "bar": "\\frac{{{num}}}{{{den}}}",
    "skw": r"^{{{num}}}/_{{{den}}}",
    "noBar": "\\genfrac{{}}{{}}{{0pt}}{{}}{{{num}}}{{{den}}}",
    "lin": "{{{num}}}/{{{den}}}",
}
F_DEFAULT = "\\frac{{{num}}}{{{den}}}"

D = "\\left{left}{text}\\right{right}"

D_DEFAULT = {
    "left": "(",
    "right": ")",
    "null": ".",
}

RAD = "\\sqrt[{deg}]{{{text}}}"

RAD_DEFAULT = "\\sqrt{{{text}}}"

ARR = "\\begin{{array}}{{c}}{text}\\end{{array}}"

LIM_FUNC = {
    "lim": "\\lim_{{{lim}}}",
    "max": "\\max_{{{lim}}}",
    "min": "\\min_{{{lim}}}",
}

LIM_TO = ("\\rightarrow", "\\to")

LIM_UPP = "\\overset{{{lim}}}{{{text}}}"

M = "\\begin{{matrix}}{text}\\end{{matrix}}"


package agent

import (
	"context"
	"errors"
	"log/slog"
	"net"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"github.com/henrygd/beszel/agent/health"
	"github.com/henrygd/beszel/agent/utils"
	"github.com/henrygd/beszel/internal/entities/system"
)

// ConnectionManager manages the connection state and events for the agent.
// It handles both WebSocket and SSH connections, automatically switching between
// them based on availability and managing reconnection attempts.
type ConnectionManager struct {
	agent          *Agent               // Reference to the parent agent
	State          ConnectionState      // Current connection state
	eventChan      chan ConnectionEvent // Channel for connection events
	wsClient       *WebSocketClient     // WebSocket client for hub communication
	serverOptions  ServerOptions        // Configuration for SSH server
	wsTicker       *time.Ticker         // Ticker for WebSocket connection attempts
	isConnecting   bool                 // Prevents multiple simultaneous reconnection attempts
	ConnectionType system.ConnectionType
}

// ConnectionState represents the current connection state of the agent.
type ConnectionState uint8

// ConnectionEvent represents connection-related events that can occur.
type ConnectionEvent uint8

// Connection states
const (
	Disconnected       ConnectionState = iota // No active connection
	WebSocketConnected                        // Connected via WebSocket
	SSHConnected                              // Connected via SSH
)

// Connection events
const (
	WebSocketConnect    ConnectionEvent = iota // WebSocket connection established
	WebSocketDisconnect                        // WebSocket connection lost
	SSHConnect                                 // SSH connection established
	SSHDisconnect                              // SSH connection lost
)

const wsTickerInterval = 10 * time.Second

// newConnectionManager creates a new connection manager for the given agent.
func newConnectionManager(agent *Agent) *ConnectionManager {
	cm := &ConnectionManager{
		agent: agent,
		State: Disconnected,
	}
	return cm
}

// startWsTicker starts or resets the WebSocket connection attempt ticker.
func (c *ConnectionManager) startWsTicker() {
	if c.wsTicker == nil {
		c.wsTicker = time.NewTicker(wsTickerInterval)
	} else {
		c.wsTicker.Reset(wsTickerInterval)
	}
}

// stopWsTicker stops the WebSocket connection attempt ticker.
func (c *ConnectionManager) stopWsTicker() {
	if c.wsTicker != nil {
		c.wsTicker.Stop()
	}
}

// Start begins connection attempts and enters the main event loop.
// It handles connection events, periodic health updates, and graceful shutdown.
func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
	if c.eventChan != nil {
		return errors.New("already started")
	}

	wsClient, err := newWebSocketClient(c.agent)
	if err != nil {
		slog.Warn("Error creating WebSocket client", "err", err)
	}
	c.wsClient = wsClient

	c.serverOptions = serverOptions
	c.eventChan = make(chan ConnectionEvent, 1)

	// signal handling for shutdown
	sigCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stopSignals()

	c.startWsTicker()
	c.connect()

	// update health status immediately and every 90 seconds
	_ = health.Update()
	healthTicker := time.Tick(90 * time.Second)

	for {
		select {
		case connectionEvent := <-c.eventChan:
			c.handleEvent(connectionEvent)
		case <-c.wsTicker.C:
			_ = c.startWebSocketConnection()
		case <-healthTicker:
			_ = health.Update()
		case <-sigCtx.Done():
			slog.Info("Shutting down", "cause", context.Cause(sigCtx))
			return c.stop()
		}
	}
}

// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
//
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
//
//	func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
//		ctx, cancel := context.WithCancel(context.Background())
//		c.cancel = cancel
//
//		for {
//			select {
//			case <-ctx.Done():
//				return c.stop()
//			}
//		}
//	}
//
//	func (c *ConnectionManager) Stop() {
//		c.cancel()
//	}
func (c *ConnectionManager) stop() error {
	_ = c.agent.StopServer()
	c.closeWebSocket()
	return health.CleanUp()
}

// handleEvent processes connection events and updates the connection state accordingly.
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
	switch event {
	case WebSocketConnect:
		c.handleStateChange(WebSocketConnected)
	case SSHConnect:
		c.handleStateChange(SSHConnected)
	case WebSocketDisconnect:
		if c.State == WebSocketConnected {
			c.handleStateChange(Disconnected)
		}
	case SSHDisconnect:
		if c.State == SSHConnected {
			c.handleStateChange(Disconnected)
		}
	}
}

// handleStateChange updates the connection state and performs necessary actions
// based on the new state, including stopping services and initiating reconnections.
func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
	if c.State == newState {
		return
	}
	c.State = newState
	switch newState {
	case WebSocketConnected:
		slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
		c.ConnectionType = system.ConnectionTypeWebSocket
		c.stopWsTicker()
		_ = c.agent.StopServer()
		c.isConnecting = false
	case SSHConnected:
		// stop new ws connection attempts
		slog.Info("SSH connection established")
		c.ConnectionType = system.ConnectionTypeSSH
		c.stopWsTicker()
		c.isConnecting = false
	case Disconnected:
		c.ConnectionType = system.ConnectionTypeNone
		if c.isConnecting {
			// Already handling reconnection, avoid duplicate attempts
			return
		}
		c.isConnecting = true
		slog.Warn("Disconnected from hub")
		// make sure old ws connection is closed
		c.closeWebSocket()
		// reconnect
		go c.connect()
	}
}

// connect handles the connection logic with proper delays and priority.
// It attempts WebSocket connection first, falling back to SSH server if needed.
func (c *ConnectionManager) connect() {
	c.isConnecting = true
	defer func() {
		c.isConnecting = false
	}()

	if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
		time.Sleep(5 * time.Second)
	}

	// Try WebSocket first, if it fails, start SSH server
	err := c.startWebSocketConnection()
	if err != nil {
		if shouldExitOnErr(err) {
			time.Sleep(2 * time.Second) // prevent tight restart loop
			_ = c.stop()
			os.Exit(1)
		}
		if c.State == Disconnected {
			c.startSSHServer()
			c.startWsTicker()
		}
	}
}

// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
func (c *ConnectionManager) startWebSocketConnection() error {
	if c.State != Disconnected {
		return errors.New("already connected")
	}
	if c.wsClient == nil {
		return errors.New("WebSocket client not initialized")
	}
	if time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
		return errors.New("already connecting")
	}

	err := c.wsClient.Connect()
	if err != nil {
		slog.Warn("WebSocket connection failed", "err", err)
		c.closeWebSocket()
	}
	return err
}

// startSSHServer starts the SSH server if the agent is currently disconnected.
func (c *ConnectionManager) startSSHServer() {
	if c.State == Disconnected {
		go c.agent.StartServer(c.serverOptions)
	}
}

// closeWebSocket closes the WebSocket connection if it exists.
func (c *ConnectionManager) closeWebSocket() {
	if c.wsClient != nil {
		c.wsClient.Close()
	}
}

// shouldExitOnErr checks if the error is a DNS resolution failure and if the
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
func shouldExitOnErr(err error) bool {
	if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
		if opErr, ok := errors.AsType[*net.OpError](err); ok {
			return strings.Contains(opErr.Err.Error(), "lookup")
		}
	}
	return false
}


from defusedxml import minidom
from xml.dom.minidom import Document, Element
from typing import BinaryIO, Any, Union
from bs4 import BeautifulSoup

from ._markdownify import _CustomMarkdownify
from .._stream_info import StreamInfo
from .._base_converter import DocumentConverter, DocumentConverterResult

PRECISE_MIME_TYPE_PREFIXES = [
    "application/rss",
    "application/rss+xml",
    "application/atom",
    "application/atom+xml",
]

PRECISE_FILE_EXTENSIONS = [".rss", ".atom"]

CANDIDATE_MIME_TYPE_PREFIXES = [
    "text/xml",
    "application/xml",
]

CANDIDATE_FILE_EXTENSIONS = [
    ".xml",
]

class RssConverter(DocumentConverter):
    """Convert RSS / Atom type to markdown"""

    def __init__(self):
        super().__init__()
        self._kwargs = {}

    def accepts(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        **kwargs: Any,  # Options to pass to the converter
    ) -> bool:
        mimetype = (stream_info.mimetype or "").lower()
        extension = (stream_info.extension or "").lower()

        # Check for precise mimetypes and file extensions
        if extension in PRECISE_FILE_EXTENSIONS:
            return True

        for prefix in PRECISE_MIME_TYPE_PREFIXES:
            if mimetype.startswith(prefix):
                return True

        # Check for precise mimetypes and file extensions
        if extension in CANDIDATE_FILE_EXTENSIONS:
            return self._check_xml(file_stream)

        for prefix in CANDIDATE_MIME_TYPE_PREFIXES:
            if mimetype.startswith(prefix):
                return self._check_xml(file_stream)

        return False

    def _check_xml(self, file_stream: BinaryIO) -> bool:
        cur_pos = file_stream.tell()
        try:
            doc = minidom.parse(file_stream)
            return self._feed_type(doc) is not None
        except BaseException as _:
            pass
        finally:
            file_stream.seek(cur_pos)
        return False

    def _feed_type(self, doc: Any) -> str | None:
        if doc.getElementsByTagName("rss"):
            return "rss"
        elif doc.getElementsByTagName("feed"):
            root = doc.getElementsByTagName("feed")[0]
            if root.getElementsByTagName("entry"):
                # An Atom feed must have a root element of <feed> and at least one <entry>
                return "atom"
        return None

    def convert(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        **kwargs: Any,  # Options to pass to the converter
    ) -> DocumentConverterResult:
        self._kwargs = kwargs
        doc = minidom.parse(file_stream)
        feed_type = self._feed_type(doc)

        if feed_type == "rss":
            return self._parse_rss_type(doc)
        elif feed_type == "atom":
            return self._parse_atom_type(doc)
        else:
            raise ValueError("Unknown feed type")

    def _parse_atom_type(self, doc: Document) -> DocumentConverterResult:
        """Parse the type of an Atom feed.

        Returns None if the feed type is not recognized or something goes wrong.
        """
        root = doc.getElementsByTagName("feed")[0]
        title = self._get_data_by_tag_name(root, "title")
        subtitle = self._get_data_by_tag_name(root, "subtitle")
        entries = root.getElementsByTagName("entry")
        md_text = f"# {title}\n"
        if subtitle:
            md_text += f"{subtitle}\n"
        for entry in entries:
            entry_title = self._get_data_by_tag_name(entry, "title")
            entry_summary = self._get_data_by_tag_name(entry, "summary")
            entry_updated = self._get_data_by_tag_name(entry, "updated")
            entry_content = self._get_data_by_tag_name(entry, "content")

            if entry_title:
                md_text += f"\n## {entry_title}\n"
            if entry_updated:
                md_text += f"Updated on: {entry_updated}\n"
            if entry_summary:
                md_text += self._parse_content(entry_summary)
            if entry_content:
                md_text += self._parse_content(entry_content)

        return DocumentConverterResult(
            markdown=md_text,
            title=title,
        )

    def _parse_rss_type(self, doc: Document) -> DocumentConverterResult:
        """Parse the type of an RSS feed.

        Returns None if the feed type is not recognized or something goes wrong.
        """
        root = doc.getElementsByTagName("rss")[0]
        channel_list = root.getElementsByTagName("channel")
        if not channel_list:
            raise ValueError("No channel found in RSS feed")
        channel = channel_list[0]
        channel_title = self._get_data_by_tag_name(channel, "title")
        channel_description = self._get_data_by_tag_name(channel, "description")
        items = channel.getElementsByTagName("item")
        if channel_title:
            md_text = f"# {channel_title}\n"
        if channel_description:
            md_text += f"{channel_description}\n"
        for item in items:
            title = self._get_data_by_tag_name(item, "title")
            description = self._get_data_by_tag_name(item, "description")
            pubDate = self._get_data_by_tag_name(item, "pubDate")
            content = self._get_data_by_tag_name(item, "content:encoded")

            if title:
                md_text += f"\n## {title}\n"
            if pubDate:
                md_text += f"Published on: {pubDate}\n"
            if description:
                md_text += self._parse_content(description)
            if content:
                md_text += self._parse_content(content)

        return DocumentConverterResult(
            markdown=md_text,
            title=channel_title,
        )

    def _parse_content(self, content: str) -> str:
        """Parse the content of an RSS feed item"""
        try:
            # using bs4 because many RSS feeds have HTML-styled content
            soup = BeautifulSoup(content, "html.parser")
            return _CustomMarkdownify(**self._kwargs).convert_soup(soup)
        except BaseException as _:
            return content

    def _get_data_by_tag_name(
        self, element: Element, tag_name: str
    ) -> Union[str, None]:
        """Get data from first child element with the given tag name.
        Returns None when no such element is found.
        """
        nodes = element.getElementsByTagName(tag_name)
        if not nodes:
            return None
        fc = nodes[0].firstChild
        if fc:
            if hasattr(fc, "data"):
                return fc.data
        return None


from time import sleep as time_sleep
from asyncio import sleep as asyncio_sleep

from playwright.sync_api import (
    Locator,
    sync_playwright,
)
from playwright.async_api import (
    async_playwright,
    Locator as AsyncLocator,
)

from scrapling.core.utils import log
from scrapling.core._types import Optional, List, ProxyType, Unpack
from scrapling.engines.toolbelt.proxy_rotation import is_proxy_error
from scrapling.engines.toolbelt.convertor import Response, ResponseFactory
from scrapling.engines._browsers._types import PlaywrightSession, PlaywrightFetchParams
from scrapling.engines._browsers._base import SyncSession, AsyncSession, DynamicSessionMixin
from scrapling.engines._browsers._validators import validate_fetch as _validate, PlaywrightConfig

class DynamicSession(SyncSession, DynamicSessionMixin):
    """A Browser session manager with page pooling."""

    __slots__ = (
        "_config",
        "_context_options",
        "_browser_options",
        "_user_data_dir",
        "_headers_keys",
        "max_pages",
        "page_pool",
        "_max_wait_for_page",
        "playwright",
        "context",
    )

    def __init__(self, **kwargs: Unpack[PlaywrightSession]):
        """A Browser session manager with page pooling, it's using a persistent browser Context by default with a temporary user profile directory.

        :param headless: Run the browser in headless/hidden (default), or headful/visible mode.
        :param disable_resources: Drop requests for unnecessary resources for a speed boost.
            Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`.
        :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too).
        :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it.
        :param cookies: Set cookies for the next request.
        :param network_idle: Wait for the page until there are no network connections for at least 500 ms.
        :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000
        :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object.
        :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need.
        :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads.
        :param wait_selector: Wait for a specific CSS selector to be in a specific state.
        :param init_script: An absolute path to a JavaScript file to be executed on page creation for all pages in this session.
        :param locale: Specify user locale, for example, `en-GB`, `de-DE`, etc. Locale will affect navigator.language value, Accept-Language request header value as well as number and date formatting
            rules. Defaults to the system default locale.
        :param timezone_id: Changes the timezone of the browser. Defaults to the system timezone.
        :param wait_selector_state: The state to wait for the selector given with `wait_selector`. The default state is `attached`.
        :param real_chrome: If you have a Chrome browser installed on your device, enable this, and the Fetcher will launch an instance of your browser and use it.
        :param load_dom: Enabled by default, wait for all JavaScript on page(s) to fully load and execute.
        :param cdp_url: Instead of launching a new browser instance, connect to this CDP URL to control real browsers through CDP.
        :param google_search: Enabled by default, Scrapling will set a Google referer header.
        :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
        :param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
        :param user_data_dir: Path to a User Data Directory, which stores browser session data like cookies and local storage. The default is to create a temporary directory.
        :param extra_flags: A list of additional browser flags to pass to the browser on launch.
        :param selector_config: The arguments that will be passed in the end while creating the final Selector's class.
        :param additional_args: Additional arguments to be passed to Playwright's context as additional settings, and it takes higher priority than Scrapling's settings.
        """
        self.__validate__(**kwargs)
        super().__init__()

    def start(self):
        """Create a browser for this instance and context."""
        if not self.playwright:
            self.playwright = sync_playwright().start()

            try:
                if self._config.cdp_url:  # pragma: no cover
                    self.browser = self.playwright.chromium.connect_over_cdp(endpoint_url=self._config.cdp_url)
                    if not self._config.proxy_rotator and self.browser:
                        self.context = self.browser.new_context(**self._context_options)
                elif self._config.proxy_rotator:
                    self.browser = self.playwright.chromium.launch(**self._browser_options)
                else:
                    persistent_options = (
                        self._browser_options | self._context_options | {"user_data_dir": self._user_data_dir}
                    )
                    self.context = self.playwright.chromium.launch_persistent_context(**persistent_options)

                if self.context:
                    self.context = self._initialize_context(self._config, self.context)

                self._is_alive = True
            except Exception:
                # Clean up playwright if browser setup fails
                self.playwright.stop()
                self.playwright = None
                raise
        else:
            raise RuntimeError("Session has been already started")

    def fetch(self, url: str, **kwargs: Unpack[PlaywrightFetchParams]) -> Response:
        """Opens up the browser and do your request based on your chosen options.

        :param url: The Target url.
        :param google_search: Enabled by default, Scrapling will set a Google referer header.
        :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000
        :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object.
        :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need.
        :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads.
        :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
        :param disable_resources: Drop requests for unnecessary resources for a speed boost.
            Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`.
        :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too).
        :param wait_selector: Wait for a specific CSS selector to be in a specific state.
        :param wait_selector_state: The state to wait for the selector given with `wait_selector`. The default state is `attached`.
        :param network_idle: Wait for the page until there are no network connections for at least 500 ms.
        :param load_dom: Enabled by default, wait for all JavaScript on page(s) to fully load and execute.
        :param selector_config: The arguments that will be passed in the end while creating the final Selector's class.
        :param proxy: Static proxy to override rotator and session proxy. A new browser context will be created and used with it.
        :return: A `Response` object.
        """
        static_proxy = kwargs.pop("proxy", None)

        params = _validate(kwargs, self, PlaywrightConfig)
        if not self._is_alive:  # pragma: no cover
            raise RuntimeError("Context manager has been closed")

        request_headers_keys = {h.lower() for h in params.extra_headers.keys()} if params.extra_headers else set()
        referer = (
            "https://www.google.com/" if (params.google_search and "referer" not in request_headers_keys) else None
        )

        for attempt in range(self._config.retries):
            proxy: Optional[ProxyType] = None
            if self._config.proxy_rotator and static_proxy is None:
                proxy = self._config.proxy_rotator.get_proxy()
            else:
                proxy = static_proxy

            with self._page_generator(
                params.timeout, params.extra_headers, params.disable_resources, proxy, params.blocked_domains
            ) as page_info:
                final_response: List = [None]
                xhr_captured: List = []
                page = page_info.page
                page.on(
                    "response",
                    self._create_response_handler(
                        page_info,
                        final_response,
                        xhr_pattern=self._config.capture_xhr,
                        xhr_container=xhr_captured,
                    ),
                )

                if params.page_setup:
                    try:
                        params.page_setup(page)
                    except Exception as e:  # pragma: no cover
                        log.error(f"Error executing page_setup: {e}")

                try:
                    first_response = page.goto(url, referer=referer)
                    self._wait_for_page_stability(page, params.load_dom, params.network_idle)

                    if not first_response:
                        raise RuntimeError(f"Failed to get response for {url}")

                    if params.page_action:
                        try:
                            _ = params.page_action(page)
                        except Exception as e:  # pragma: no cover
                            log.error(f"Error executing page_action: {e}")

                    if params.wait_selector:
                        try:
                            waiter: Locator = page.locator(params.wait_selector)
                            waiter.first.wait_for(state=params.wait_selector_state)
                            self._wait_for_page_stability(page, params.load_dom, params.network_idle)
                        except Exception as e:  # pragma: no cover
                            log.error(f"Error waiting for selector {params.wait_selector}: {e}")

                    page.wait_for_timeout(params.wait)

                    response = ResponseFactory.from_playwright_response(
                        page,
                        first_response,
                        final_response[0],
                        params.selector_config,
                        meta={"proxy": proxy},
                        xhr_captured=xhr_captured,
                    )
                    return response

                except Exception as e:
                    page_info.mark_error()
                    if attempt < self._config.retries - 1:
                        if is_proxy_error(e):
                            log.warning(
                                f"Proxy '{proxy}' failed (attempt {attempt + 1}) | Retrying in {self._config.retry_delay}s..."
                            )
                        else:
                            log.warning(
                                f"Attempt {attempt + 1} failed: {e}. Retrying in {self._config.retry_delay}s..."
                            )
                        time_sleep(self._config.retry_delay)
                    else:
                        log.error(f"Failed after {self._config.retries} attempts: {e}")
                        raise

        raise RuntimeError("Request failed")  # pragma: no cover

class AsyncDynamicSession(AsyncSession, DynamicSessionMixin):
    """An async Browser session manager with page pooling, it's using a persistent browser Context by default with a temporary user profile directory."""

    __slots__ = (
        "_config",
        "_context_options",
        "_browser_options",
        "_user_data_dir",
        "_headers_keys",
    )

    def __init__(self, **kwargs: Unpack[PlaywrightSession]):
        """A Browser session manager with page pooling

        :param headless: Run the browser in headless/hidden (default), or headful/visible mode.
        :param disable_resources: Drop requests for unnecessary resources for a speed boost.
            Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`.
        :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too).
        :param useragent: Pass a useragent string to be used. Otherwise the fetcher will generate a real Useragent of the same browser and use it.
        :param cookies: Set cookies for the next request.
        :param network_idle: Wait for the page until there are no network connections for at least 500 ms.
        :param load_dom: Enabled by default, wait for all JavaScript on page(s) to fully load and execute.
        :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000
        :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object.
        :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need.
        :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads.
        :param wait_selector: Wait for a specific CSS selector to be in a specific state.
        :param init_script: An absolute path to a JavaScript file to be executed on page creation for all pages in this session.
        :param locale: Specify user locale, for example, `en-GB`, `de-DE`, etc. Locale will affect navigator.language value, Accept-Language request header value as well as number and date formatting
            rules. Defaults to the system default locale.
        :param timezone_id: Changes the timezone of the browser. Defaults to the system timezone.
        :param wait_selector_state: The state to wait for the selector given with `wait_selector`. The default state is `attached`.
        :param real_chrome: If you have a Chrome browser installed on your device, enable this, and the Fetcher will launch an instance of your browser and use it.
        :param cdp_url: Instead of launching a new browser instance, connect to this CDP URL to control real browsers through CDP.
        :param google_search: Enabled by default, Scrapling will set a Google referer header.
        :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
        :param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
        :param max_pages: The maximum number of tabs to be opened at the same time. It will be used in rotation through a PagePool.
        :param user_data_dir: Path to a User Data Directory, which stores browser session data like cookies and local storage. The default is to create a temporary directory.
        :param extra_flags: A list of additional browser flags to pass to the browser on launch.
        :param selector_config: The arguments that will be passed in the end while creating the final Selector's class.
        :param additional_args: Additional arguments to be passed to Playwright's context as additional settings, and it takes higher priority than Scrapling's settings.
        """
        self.__validate__(**kwargs)
        super().__init__(max_pages=self._config.max_pages)

    async def start(self) -> None:
        """Create a browser for this instance and context."""
        if not self.playwright:
            self.playwright = await async_playwright().start()
            try:
                if self._config.cdp_url:
                    self.browser = await self.playwright.chromium.connect_over_cdp(endpoint_url=self._config.cdp_url)
                    if not self._config.proxy_rotator and self.browser:
                        self.context = await self.browser.new_context(**self._context_options)
                elif self._config.proxy_rotator:
                    self.browser = await self.playwright.chromium.launch(**self._browser_options)
                else:
                    persistent_options = (
                        self._browser_options | self._context_options | {"user_data_dir": self._user_data_dir}
                    )
                    self.context = await self.playwright.chromium.launch_persistent_context(**persistent_options)

                if self.context:
                    self.context = await self._initialize_context(self._config, self.context)

                self._is_alive = True
            except Exception:
                # Clean up playwright if browser setup fails
                await self.playwright.stop()
                self.playwright = None
                raise
        else:
            raise RuntimeError("Session has been already started")

    async def fetch(self, url: str, **kwargs: Unpack[PlaywrightFetchParams]) -> Response:
        """Opens up the browser and do your request based on your chosen options.

        :param url: The Target url.
        :param google_search: Enabled by default, Scrapling will set a Google referer header.
        :param timeout: The timeout in milliseconds that is used in all operations and waits through the page. The default is 30,000
        :param wait: The time (milliseconds) the fetcher will wait after everything finishes before closing the page and returning the ` Response ` object.
        :param page_action: Added for automation. A function that takes the `page` object, runs after navigation, and does the automation you need.
        :param page_setup: A function that takes the `page` object, runs before navigation. Use it to register event listeners or routes that must be set up before the page loads.
        :param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
        :param disable_resources: Drop requests for unnecessary resources for a speed boost.
            Requests dropped are of type `font`, `image`, `media`, `beacon`, `object`, `imageset`, `texttrack`, `websocket`, `csp_report`, and `stylesheet`.
        :param blocked_domains: A set of domain names to block requests to. Subdomains are also matched (e.g., ``"example.com"`` blocks ``"sub.example.com"`` too).
        :param wait_selector: Wait for a specific CSS selector to be in a specific state.
        :param wait_selector_state: The state to wait for the selector given with `wait_selector`. The default state is `attached`.
        :param network_idle: Wait for the page until there are no network connections for at least 500 ms.
        :param load_dom: Enabled by default, wait for all JavaScript on page(s) to fully load and execute.
        :param selector_config: The arguments that will be passed in the end while creating the final Selector's class.
        :param proxy: Static proxy to override rotator and session proxy. A new browser context will be created and used with it.
        :return: A `Response` object.
        """
        static_proxy = kwargs.pop("proxy", None)

        params = _validate(kwargs, self, PlaywrightConfig)

        if not self._is_alive:  # pragma: no cover
            raise RuntimeError("Context manager has been closed")

        request_headers_keys = {h.lower() for h in params.extra_headers.keys()} if params.extra_headers else set()
        referer = (
            "https://www.google.com/" if (params.google_search and "referer" not in request_headers_keys) else None
        )

        for attempt in range(self._config.retries):
            proxy: Optional[ProxyType] = None
            if self._config.proxy_rotator and static_proxy is None:
                proxy = self._config.proxy_rotator.get_proxy()
            else:
                proxy = static_proxy

            async with self._page_generator(
                params.timeout, params.extra_headers, params.disable_resources, proxy, params.blocked_domains
            ) as page_info:
                final_response: List = [None]
                xhr_captured: List = []
                page = page_info.page
                page.on(
                    "response",
                    self._create_response_handler(
                        page_info,
                        final_response,
                        xhr_pattern=self._config.capture_xhr,
                        xhr_container=xhr_captured,
                    ),
                )

                if params.page_setup:
                    try:
                        await params.page_setup(page)
                    except Exception as e:  # pragma: no cover
                        log.error(f"Error executing page_setup: {e}")

                try:
                    first_response = await page.goto(url, referer=referer)
                    await self._wait_for_page_stability(page, params.load_dom, params.network_idle)

                    if not first_response:
                        raise RuntimeError(f"Failed to get response for {url}")

                    if params.page_action:
                        try:
                            _ = await params.page_action(page)
                        except Exception as e:  # pragma: no cover
                            log.error(f"Error executing page_action: {e}")

                    if params.wait_selector:
                        try:
                            waiter: AsyncLocator = page.locator(params.wait_selector)
                            await waiter.first.wait_for(state=params.wait_selector_state)
                            await self._wait_for_page_stability(page, params.load_dom, params.network_idle)
                        except Exception as e:  # pragma: no cover
                            log.error(f"Error waiting for selector {params.wait_selector}: {e}")

                    await page.wait_for_timeout(params.wait)

                    response = await ResponseFactory.from_async_playwright_response(
                        page,
                        first_response,
                        final_response[0],
                        params.selector_config,
                        meta={"proxy": proxy},
                        xhr_captured=xhr_captured,
                    )
                    return response

                except Exception as e:
                    page_info.mark_error()
                    if attempt < self._config.retries - 1:
                        if is_proxy_error(e):
                            log.warning(
                                f"Proxy '{proxy}' failed (attempt {attempt + 1}) | Retrying in {self._config.retry_delay}s..."
                            )
                        else:
                            log.warning(
                                f"Attempt {attempt + 1} failed: {e}. Retrying in {self._config.retry_delay}s..."
                            )
                        await asyncio_sleep(self._config.retry_delay)
                    else:
                        log.error(f"Failed after {self._config.retries} attempts: {e}")
                        raise

        raise RuntimeError("Request failed")  # pragma: no cover


import re
import markdownify

from typing import Any, Optional
from urllib.parse import quote, unquote, urlparse, urlunparse

class _CustomMarkdownify(markdownify.MarkdownConverter):
    """
    A custom version of markdownify's MarkdownConverter. Changes include:

    - Altering the default heading style to use '#', '##', etc.
    - Removing javascript hyperlinks.
    - Truncating images with large data:uri sources.
    - Ensuring URIs are properly escaped, and do not conflict with Markdown syntax
    """

    def __init__(self, **options: Any):
        options["heading_style"] = options.get("heading_style", markdownify.ATX)
        options["keep_data_uris"] = options.get("keep_data_uris", False)
        # Explicitly cast options to the expected type if necessary
        super().__init__(**options)

    def convert_hn(
        self,
        n: int,
        el: Any,
        text: str,
        convert_as_inline: Optional[bool] = False,
        **kwargs,
    ) -> str:
        """Same as usual, but be sure to start with a new line"""
        if not convert_as_inline:
            if not re.search(r"^\n", text):
                return "\n" + super().convert_hn(n, el, text, convert_as_inline)  # type: ignore

        return super().convert_hn(n, el, text, convert_as_inline)  # type: ignore

    def convert_a(
        self,
        el: Any,
        text: str,
        convert_as_inline: Optional[bool] = False,
        **kwargs,
    ):
        """Same as usual converter, but removes JavaScript links and escapes URIs."""
        prefix, suffix, text = markdownify.chomp(text)  # type: ignore
        if not text:
            return ""

        if el.find_parent("pre") is not None:
            return text

        href = el.get("href")
        title = el.get("title")

        # Escape URIs and skip non-http or file schemes
        if href:
            try:
                parsed_url = urlparse(href)  # type: ignore
                if parsed_url.scheme and parsed_url.scheme.lower() not in ["http", "https", "file"]:  # type: ignore
                    return "%s%s%s" % (prefix, text, suffix)
                href = urlunparse(parsed_url._replace(path=quote(unquote(parsed_url.path))))  # type: ignore
            except ValueError:  # It's not clear if this ever gets thrown
                return "%s%s%s" % (prefix, text, suffix)

        # For the replacement see #29: text nodes underscores are escaped
        if (
            self.options["autolinks"]
            and text.replace(r"\_", "_") == href
            and not title
            and not self.options["default_title"]
        ):
            # Shortcut syntax
            return "<%s>" % href
        if self.options["default_title"] and not title:
            title = href
        title_part = ' "%s"' % title.replace('"', r"\"") if title else ""
        return (
            "%s[%s](%s%s)%s" % (prefix, text, href, title_part, suffix)
            if href
            else text
        )

    def convert_img(
        self,
        el: Any,
        text: str,
        convert_as_inline: Optional[bool] = False,
        **kwargs,
    ) -> str:
        """Same as usual converter, but removes data URIs"""

        alt = el.attrs.get("alt", None) or ""
        src = el.attrs.get("src", None) or el.attrs.get("data-src", None) or ""
        title = el.attrs.get("title", None) or ""
        title_part = ' "%s"' % title.replace('"', r"\"") if title else ""
        # Remove all line breaks from alt
        alt = alt.replace("\n", " ")
        if (
            convert_as_inline
            and el.parent.name not in self.options["keep_inline_images_in"]
        ):
            return alt

        # Remove dataURIs
        if src.startswith("data:") and not self.options["keep_data_uris"]:
            src = src.split(",")[0] + "..."

        return "![%s](%s%s)" % (alt, src, title_part)

    def convert_input(
        self,
        el: Any,
        text: str,
        convert_as_inline: Optional[bool] = False,
        **kwargs,
    ) -> str:
        """Convert checkboxes to Markdown [x]/[ ] syntax."""

        if el.get("type") == "checkbox":
            return "[x] " if el.has_attr("checked") else "[ ] "
        return ""

    def convert_soup(self, soup: Any) -> str:
        return super().convert_soup(soup)  # type: ignore


"""Tests for download filename sanitization (GHSA-rv9j-wqjp-2fv4,
GHSA-66xh-g88g-2h8j, GHSA-hpr4-fqgr-xhj9).

`DownloadsWatchdog` historically joined attacker-controlled filenames from CDP
(`Page.downloadWillBegin.suggestedFilename`) and `Content-Disposition` headers
directly into the configured `downloads_path`. Strings like `../../escape.bin`
or `/etc/shadow.bak` would `os.path.join` outside the downloads directory,
writing the fetched bytes (also attacker-controlled — the response body is the
exploit content) to an arbitrary location with the agent's process privileges.

`download_file_from_url` triggers passively for any
`Content-Disposition: attachment` response, so this is reachable from any
visited site — `allowed_domains` does not mitigate it.

The fix funnels every attacker-controlled filename through
`DownloadsWatchdog._sanitize_download_filename`, which keeps only the basename
and rejects pure-traversal names. Each on-disk sink additionally verifies
containment via `os.path.realpath`.
"""

from __future__ import annotations

from pathlib import Path

from browser_use.browser.watchdogs.downloads_watchdog import DownloadsWatchdog

class TestSanitizeDownloadFilename:
	def test_strips_relative_traversal(self) -> None:
		assert DownloadsWatchdog._sanitize_download_filename('../../etc/passwd') == 'passwd'

	def test_strips_absolute_unix_path(self) -> None:
		assert DownloadsWatchdog._sanitize_download_filename('/etc/shadow') == 'shadow'

	def test_strips_windows_backslash_paths(self) -> None:
		assert DownloadsWatchdog._sanitize_download_filename('..\\..\\Windows\\System32\\config.txt') == 'config.txt'

	def test_strips_mixed_separators(self) -> None:
		assert DownloadsWatchdog._sanitize_download_filename('a/b\\c/../d.pdf') == 'd.pdf'

	def test_pure_traversal_falls_back_to_download(self) -> None:
		for malicious in ('..', '.', '/', '\\', '../', '..\\', '/.', '\\.', '/..'):
			assert DownloadsWatchdog._sanitize_download_filename(malicious) == 'download', (
				f'{malicious!r} should fall back to default'
			)

	def test_null_byte_stripped(self) -> None:
		# Null bytes can be used to confuse path handling on some platforms.
		assert DownloadsWatchdog._sanitize_download_filename('file.txt\x00.exe') == 'file.txt.exe'

	def test_empty_or_none_falls_back(self) -> None:
		assert DownloadsWatchdog._sanitize_download_filename('') == 'download'
		assert DownloadsWatchdog._sanitize_download_filename(None) == 'download'

	def test_normal_filenames_preserved(self) -> None:
		# Make sure we don't over-sanitize legitimate names.
		assert DownloadsWatchdog._sanitize_download_filename('report.pdf') == 'report.pdf'
		assert DownloadsWatchdog._sanitize_download_filename('file with spaces.pdf') == 'file with spaces.pdf'
		assert DownloadsWatchdog._sanitize_download_filename('file-with_underscores.csv') == 'file-with_underscores.csv'
		# Dotfiles are allowed as long as they're not just dots.
		assert DownloadsWatchdog._sanitize_download_filename('.bashrc') == '.bashrc'

	def test_unicode_preserved(self) -> None:
		# Filenames with non-ASCII characters should survive (common for i18n filenames).
		assert DownloadsWatchdog._sanitize_download_filename('résumé.pdf') == 'résumé.pdf'
		assert DownloadsWatchdog._sanitize_download_filename('文档.pdf') == '文档.pdf'

class TestIsPathContained:
	def test_file_inside_dir_returns_true(self, tmp_path: Path) -> None:
		f = tmp_path / 'a.txt'
		f.write_text('x')
		assert DownloadsWatchdog._is_path_contained(f, tmp_path) is True

	def test_nested_file_inside_dir_returns_true(self, tmp_path: Path) -> None:
		nested = tmp_path / 'sub' / 'a.txt'
		nested.parent.mkdir()
		nested.write_text('x')
		assert DownloadsWatchdog._is_path_contained(nested, tmp_path) is True

	def test_escaping_path_returns_false(self, tmp_path: Path) -> None:
		escape = tmp_path / '..' / 'a.txt'
		assert DownloadsWatchdog._is_path_contained(escape, tmp_path) is False

	def test_dir_itself_returns_true(self, tmp_path: Path) -> None:
		assert DownloadsWatchdog._is_path_contained(tmp_path, tmp_path) is True

	def test_sibling_dir_returns_false(self, tmp_path: Path) -> None:
		sibling = tmp_path.parent / (tmp_path.name + '_sibling')
		sibling.mkdir(exist_ok=True)
		try:
			f = sibling / 'a.txt'
			f.write_text('x')
			assert DownloadsWatchdog._is_path_contained(f, tmp_path) is False
		finally:
			f.unlink(missing_ok=True)
			sibling.rmdir()

class TestUniqueFilenameOperatesOnSanitizedBasename:
	"""`_get_unique_filename` must only ever receive a sanitized basename; if a
	traversal string slips through, the (1)/(2) collision-avoidance logic
	silently writes outside the intended directory."""

	async def test_unique_filename_on_basename_stays_inside_dir(self, tmp_path: Path) -> None:
		# Pre-sanitized name — what the caller should always pass.
		result = await DownloadsWatchdog._get_unique_filename(str(tmp_path), 'report.pdf')
		assert result == 'report.pdf'
		# The resolved path lives inside tmp_path.
		assert DownloadsWatchdog._is_path_contained(tmp_path / result, tmp_path)

	async def test_unique_filename_collision_handling_stays_inside_dir(self, tmp_path: Path) -> None:
		(tmp_path / 'report.pdf').write_text('x')
		result = await DownloadsWatchdog._get_unique_filename(str(tmp_path), 'report.pdf')
		assert result == 'report (1).pdf'
		assert DownloadsWatchdog._is_path_contained(tmp_path / result, tmp_path)


import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { getTaskProgressForChange } from '../../src/utils/task-progress.js';
import { resolveArtifactOutputs } from '../../src/core/artifact-graph/index.js';

/**
 * #1202 — task progress is resolved through the tracked-tasks artifact's
 * `generates` glob (the same file-resolution `openspec status` uses), not a
 * fixed `changes/<name>/tasks.md` path.
 */
describe('getTaskProgressForChange (#1202 tracked-tasks resolution)', () => {
  let projectRoot: string;
  let changesDir: string;

  const GLOB_SCHEMA = [
    'name: glob-tasks',
    'version: 1',
    'description: tasks artifact uses a nested glob',
    'artifacts:',
    '  - id: proposal',
    '    generates: proposal.md',
    '    description: Proposal',
    '    template: proposal.md',
    '    requires: []',
    '  - id: tasks',
    '    generates: "**/tasks.md"',
    '    description: Nested tasks',
    '    template: tasks.md',
    '    requires: [proposal]',
    'apply:',
    '  requires: [tasks]',
    '  tracks: "**/tasks.md"',
    '',
  ].join('\n');

  beforeEach(async () => {
    projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-taskprogress-'));
    changesDir = path.join(projectRoot, 'openspec', 'changes');
    await fs.mkdir(changesDir, { recursive: true });
  });

  afterEach(async () => {
    await fs.rm(projectRoot, { recursive: true, force: true });
  });

  async function writeGlobSchema(): Promise<void> {
    const schemaDir = path.join(projectRoot, 'openspec', 'schemas', 'glob-tasks');
    await fs.mkdir(schemaDir, { recursive: true });
    await fs.writeFile(path.join(schemaDir, 'schema.yaml'), GLOB_SCHEMA, 'utf-8');
  }

  async function writeChange(name: string, files: Record<string, string>, schema = 'glob-tasks'): Promise<string> {
    const changeDir = path.join(changesDir, name);
    await fs.mkdir(changeDir, { recursive: true });
    if (schema) {
      await fs.writeFile(path.join(changeDir, '.openspec.yaml'), `schema: ${schema}\n`, 'utf-8');
    }
    for (const [rel, content] of Object.entries(files)) {
      const full = path.join(changeDir, rel);
      await fs.mkdir(path.dirname(full), { recursive: true });
      await fs.writeFile(full, content, 'utf-8');
    }
    return changeDir;
  }

  it('aggregates checkboxes across nested tasks.md files matched by the glob', async () => {
    await writeGlobSchema();
    await writeChange('globchange', {
      'backend/tasks.md': '- [x] 1.1 a\n- [x] 1.2 b\n',
      'frontend/tasks.md': '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n',
    });

    const progress = await getTaskProgressForChange(changesDir, 'globchange', projectRoot);
    expect(progress).toEqual({ total: 5, completed: 3 });
  });

  it('resolves the same set of files status resolves (resolution-mechanism parity)', async () => {
    await writeGlobSchema();
    const changeDir = await writeChange('globchange', {
      'backend/tasks.md': '- [x] a\n- [x] b\n',
      'frontend/tasks.md': '- [x] a\n- [ ] b\n- [ ] c\n',
    });

    // `status` detects the tasks artifact via resolveArtifactOutputs(changeDir, generates).
    const statusFiles = resolveArtifactOutputs(changeDir, '**/tasks.md');
    expect(statusFiles).toHaveLength(2);

    // The helper's aggregate equals the checkbox sum over exactly those files.
    let total = 0;
    let completed = 0;
    for (const file of statusFiles) {
      const content = await fs.readFile(file, 'utf-8');
      total += (content.match(/^[-*]\s+\[[\sx]\]/gim) ?? []).length;
      completed += (content.match(/^[-*]\s+\[x\]/gim) ?? []).length;
    }
    const progress = await getTaskProgressForChange(changesDir, 'globchange', projectRoot);
    expect(progress).toEqual({ total, completed });
  });

  it('scopes resolution to the change dir (excludes archive/ and sibling changes)', async () => {
    await writeGlobSchema();
    await writeChange('target', { 'backend/tasks.md': '- [x] a\n- [ ] b\n' });
    // Decoys that must NOT be counted.
    await fs.mkdir(path.join(changesDir, 'archive', 'old'), { recursive: true });
    await fs.writeFile(path.join(changesDir, 'archive', 'old', 'tasks.md'), '- [x] x\n- [x] y\n', 'utf-8');
    await writeChange('sibling', { 'backend/tasks.md': '- [x] s1\n- [x] s2\n' });

    const progress = await getTaskProgressForChange(changesDir, 'target', projectRoot);
    expect(progress).toEqual({ total: 2, completed: 1 });
  });

  it('identifies the tracked artifact by apply.tracks even when it is not named "tasks"', async () => {
    const schemaDir = path.join(projectRoot, 'openspec', 'schemas', 'custom-track');
    await fs.mkdir(schemaDir, { recursive: true });
    await fs.writeFile(
      path.join(schemaDir, 'schema.yaml'),
      [
        'name: custom-track',
        'version: 1',
        'artifacts:',
        '  - id: proposal',
        '    generates: proposal.md',
        '    description: Proposal',
        '    template: proposal.md',
        '    requires: []',
        '  - id: checklist',
        '    generates: "work/*.md"',
        '    description: Work checklist',
        '    template: tasks.md',
        '    requires: [proposal]',
        'apply:',
        '  requires: [checklist]',
        '  tracks: "work/*.md"',
        '',
      ].join('\n'),
      'utf-8'
    );
    await writeChange('customchange', { 'work/a.md': '- [x] a\n- [ ] b\n' }, 'custom-track');

    const progress = await getTaskProgressForChange(changesDir, 'customchange', projectRoot);
    expect(progress).toEqual({ total: 2, completed: 1 });
  });

  it('falls back to a single top-level tasks.md when the schema cannot be resolved (no crash)', async () => {
    await writeChange('badschema', { 'tasks.md': '- [x] a\n- [ ] b\n' }, 'does-not-exist');

    const progress = await getTaskProgressForChange(changesDir, 'badschema', projectRoot);
    expect(progress).toEqual({ total: 2, completed: 1 });
  });

  it('counts a single top-level tasks.md unchanged under the default schema', async () => {
    // No project-local schema, no .openspec.yaml -> default spec-driven (tracks tasks.md).
    await writeChange('plain', { 'tasks.md': '- [x] a\n- [x] b\n- [ ] c\n' }, '');

    const progress = await getTaskProgressForChange(changesDir, 'plain', projectRoot);
    expect(progress).toEqual({ total: 3, completed: 2 });
  });

  it('reports zero tasks when no file matches the tracked glob', async () => {
    await writeGlobSchema();
    await writeChange('notasks', {}); // schema set, but no tasks.md anywhere

    const progress = await getTaskProgressForChange(changesDir, 'notasks', projectRoot);
    expect(progress).toEqual({ total: 0, completed: 0 });
  });
});


//! Portable model-contract scenarios recorded through Doubleword's live API.

use rig::prelude::*;
use rig_agent::test_utils::{
    cancellation_and_max_turns, hook_rewrites_and_request_patch, invalid_tool_recovery,
    parallel_tools, streaming_structured_after_tool, streaming_tool, structured_after_tool,
    structured_extraction, tool_choice_modes, tool_output_serialization, zero_argument_tool,
};

use super::super::{DEFAULT_MODEL, TOOL_MODEL, support::with_doubleword_cassette};

#[tokio::test]
async fn zero_argument_tool_roundtrip() {
    with_doubleword_cassette("conformance/zero_argument_tool", |client| async move {
        zero_argument_tool(client.completion_model(TOOL_MODEL), |builder| builder)
            .await
            .expect("zero-argument tool should succeed");
    })
    .await;
}

#[tokio::test]
async fn parallel_tool_calls_roundtrip() {
    with_doubleword_cassette("conformance/parallel_tools", |client| async move {
        parallel_tools(client.completion_model(TOOL_MODEL), |builder| builder, None)
            .await
            .expect("parallel tool calls should succeed");
    })
    .await;
}

#[tokio::test]
async fn cancellation_and_max_turn_diagnostics() {
    with_doubleword_cassette(
        "conformance/cancellation_and_max_turns",
        |client| async move {
            cancellation_and_max_turns(client.completion_model(TOOL_MODEL), |builder| builder)
                .await
                .expect("cancellation and max-turn diagnostics should succeed");
        },
    )
    .await;
}

#[tokio::test]
async fn tool_output_types_roundtrip() {
    with_doubleword_cassette(
        "conformance/tool_output_serialization",
        |client| async move {
            tool_output_serialization(client.completion_model(TOOL_MODEL), |builder| builder)
                .await
                .expect("tool output serialization should succeed");
        },
    )
    .await;
}

#[tokio::test]
async fn invalid_tool_call_recovers() {
    with_doubleword_cassette("conformance/invalid_tool_recovery", |client| async move {
        invalid_tool_recovery(client.completion_model(TOOL_MODEL), |builder| builder)
            .await
            .expect("invalid tool call recovery should succeed");
    })
    .await;
}

#[tokio::test]
async fn hooks_rewrite_tool_flow() {
    with_doubleword_cassette(
        "conformance/hook_rewrites_and_request_patch",
        |client| async move {
            hook_rewrites_and_request_patch(client.completion_model(TOOL_MODEL), |builder| builder)
                .await
                .expect("hook rewrite scenario should succeed");
        },
    )
    .await;
}

#[tokio::test]
async fn streaming_tool_roundtrip() {
    with_doubleword_cassette("conformance/streaming_tool", |client| async move {
        streaming_tool(client.completion_model(TOOL_MODEL), |builder| builder)
            .await
            .expect("streaming tool should succeed");
    })
    .await;
}

#[tokio::test]
async fn structured_output_after_tool() {
    with_doubleword_cassette("conformance/structured_after_tool", |client| async move {
        structured_after_tool(client.completion_model(TOOL_MODEL), |builder| builder)
            .await
            .expect("structured output after tool should succeed");
    })
    .await;
}

#[tokio::test]
async fn streaming_structured_output_after_tool() {
    with_doubleword_cassette(
        "conformance/streaming_structured_after_tool",
        |client| async move {
            streaming_structured_after_tool(client.completion_model(TOOL_MODEL), |builder| builder)
                .await
                .expect("streaming structured output after tool should succeed");
        },
    )
    .await;
}

#[tokio::test]
async fn structured_extraction_roundtrip() {
    with_doubleword_cassette("conformance/structured_extraction", |client| async move {
        structured_extraction(client.completion_model(DEFAULT_MODEL))
            .await
            .expect("structured extraction should succeed");
    })
    .await;
}

#[tokio::test]
async fn tool_choice_modes_roundtrip() {
    with_doubleword_cassette("conformance/tool_choice_modes", |client| async move {
        tool_choice_modes(client.completion_model(TOOL_MODEL))
            .await
            .expect("tool choice modes should succeed");
    })
    .await;
}


import { confirm } from "@inquirer/prompts";
import { spawn } from "child_process";
import { Command } from "commander";
import pc from "picocolors";
import { VERSION } from "../constants.js";
import { log } from "../utils/logger.js";
import { trackEvent } from "../utils/tracking.js";
import {
  checkForUpdates,
  getUpgradePlan,
  markUpdateNotificationShown,
  shouldShowUpdateNotification,
  shouldSkipUpdateNotifier,
  type UpgradePlan,
} from "../utils/update-check.js";

interface UpgradeOptions {
  yes?: boolean;
  check?: boolean;
}

export function registerUpgradeCommand(program: Command): void {
  program
    .command("upgrade")
    .description("Check for a newer ctx7 version and upgrade when possible")
    .option("-y, --yes", "Run the suggested upgrade command without prompting")
    .option("--check", "Only check for updates without running the upgrade command")
    .action(async (options: UpgradeOptions) => {
      await upgradeCommand(options);
    });
}

function runCommand(command: string, args: string[]): Promise<number | null> {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, {
      stdio: "inherit",
      shell: process.platform === "win32",
    });

    child.on("error", reject);
    child.on("close", (code) => resolve(code));
  });
}

export async function runUpgradePlan(plan: UpgradePlan): Promise<number | null> {
  return runCommand(plan.command, plan.args);
}

function showUpgradeFailureHelp(plan: UpgradePlan): void {
  log.info(`Try rerunning: ${pc.cyan(plan.displayCommand)}`);

  const isGlobalNpmInstall =
    (plan.installMethod === "npm-global" || plan.installMethod === "unknown") &&
    plan.command === "npm" &&
    plan.args.includes("-g");
  const isGlobalAltInstall =
    (plan.installMethod === "pnpm-global" || plan.installMethod === "bun-global") &&
    plan.args.includes("-g");

  if (isGlobalNpmInstall) {
    log.dim(
      "If this failed due to permissions, your global npm directory may require elevated privileges on this machine."
    );
  } else if (isGlobalAltInstall) {
    log.dim(
      "If this failed due to permissions, your global package manager install location may require additional privileges on this machine."
    );
  }
}

export async function maybeShowUpgradeNotice(
  options: {
    actionName?: string;
    argv?: string[];
    isInteractive?: boolean;
  } = {}
): Promise<void> {
  const actionName = options.actionName ?? "";
  const argv = options.argv ?? process.argv;
  const isInteractive =
    options.isInteractive ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);

  if (!isInteractive || shouldSkipUpdateNotifier(argv) || actionName === "upgrade") {
    return;
  }

  const info = await checkForUpdates();
  if (!info || !info.updateAvailable || !(await shouldShowUpdateNotification(info))) {
    return;
  }

  log.blank();
  if (info.upgradePlan.needsExplicitVersion) {
    log.box([
      `${pc.white(pc.bold("Update available:"))} ${pc.green(pc.bold(`v${info.currentVersion}`))} ${pc.dim("->")} ${pc.green(pc.bold(`v${info.latestVersion}`))}`,
      `${pc.white("Use")} ${pc.yellow(pc.bold(info.upgradePlan.displayCommand))} ${pc.white("to run the latest version")}`,
    ]);
    await markUpdateNotificationShown(info.latestVersion);
    log.blank();
    return;
  }

  if (!info.upgradePlan.canRun) {
    log.box([
      `${pc.white(pc.bold("Update available:"))} ${pc.green(pc.bold(`v${info.currentVersion}`))} ${pc.dim("->")} ${pc.green(pc.bold(`v${info.latestVersion}`))}`,
      `${pc.white("Run")} ${pc.yellow(pc.bold("ctx7 upgrade"))} ${pc.white("for update steps")}`,
      `${pc.white("Or run")} ${pc.yellow(info.upgradePlan.displayCommand)}`,
    ]);
    await markUpdateNotificationShown(info.latestVersion);
    log.blank();
    return;
  }

  log.box([
    `${pc.white(pc.bold("Update available:"))} ${pc.green(pc.bold(`v${info.currentVersion}`))} ${pc.dim("->")} ${pc.green(pc.bold(`v${info.latestVersion}`))}`,
    `${pc.white("Run")} ${pc.yellow(pc.bold("ctx7 upgrade"))} ${pc.white("to update now")}`,
    `${pc.white("Or run")} ${pc.yellow(info.upgradePlan.displayCommand)}`,
  ]);
  await markUpdateNotificationShown(info.latestVersion);
  log.blank();
}

async function upgradeCommand(options: UpgradeOptions): Promise<void> {
  trackEvent("command", { name: "upgrade" });

  const info = await checkForUpdates({ force: true });
  const plan = info?.upgradePlan ?? getUpgradePlan();

  if (!info) {
    log.warn("Couldn't check for updates right now.");
    log.info(`Try again later or run ${pc.cyan(plan.displayCommand)} manually.`);
    return;
  }

  if (!info.updateAvailable) {
    log.success(`ctx7 is up to date (${pc.bold(`v${VERSION}`)})`);
    return;
  }

  log.blank();
  log.info(
    `Update available: ${pc.bold(`v${info.currentVersion}`)} ${pc.dim("->")} ${pc.bold(`v${info.latestVersion}`)}`
  );

  if (plan.needsExplicitVersion) {
    log.info(`You're using an ephemeral runner (${plan.installMethod}).`);
    log.info(`Use ${pc.cyan(plan.displayCommand)} to run the latest version immediately.`);
    log.info(`Or install globally with ${pc.cyan("npm install -g ctx7@latest")}.`);
    return;
  }

  if (!plan.canRun) {
    log.info(`Run ${pc.cyan(plan.displayCommand)} to update your installed version.`);
    return;
  }

  log.info(`Upgrade command: ${pc.cyan(plan.displayCommand)}`);

  if (options.check) {
    return;
  }

  let shouldRun = options.yes ?? false;
  if (!shouldRun && process.stdout.isTTY) {
    shouldRun = await confirm({
      message: `Run ${plan.displayCommand} now?`,
      default: true,
    });
  }

  if (!shouldRun) {
    log.dim("Upgrade skipped.");
    return;
  }

  log.blank();
  const exitCode = await runUpgradePlan(plan);

  if (exitCode === 0) {
    log.blank();
    log.success("Upgrade complete.");
    log.info(`Run ${pc.cyan("ctx7 --version")} to verify the installed version.`);
    return;
  }

  log.blank();
  log.error(`Upgrade command exited with code ${exitCode ?? "unknown"}.`);
  showUpgradeFailureHelp(plan);
  process.exitCode = 1;
}


## Context

This is Slice 2 of the artifact tracker POC. The goal is to provide utilities for creating change directories programmatically.

**Current state:** No programmatic way to create changes. Users must manually create directories.

**Proposed state:** Utility functions for change creation with name validation.

## Goals / Non-Goals

### Goals
- **Add** `createChange()` function to create change directories
- **Add** `validateChangeName()` function for kebab-case validation
- **Enable** automation (Claude commands, scripts) to create changes

### Non-Goals
- Refactor existing CLI commands (they work fine)
- Create abstraction layers or manager classes
- Change how `ListCommand` or `ChangeCommand` work

## Decisions

### Decision 1: Simple Utility Functions

**Choice**: Add functions to `src/utils/change-utils.ts` - no class.

```typescript
// src/utils/change-utils.ts

export function validateChangeName(name: string): { valid: boolean; error?: string }

export async function createChange(
  projectRoot: string,
  name: string
): Promise<void>
```

**Why**:
- Simple, no abstraction overhead
- Easy to test
- Easy to import where needed
- Matches existing utility patterns in `src/utils/`

**Alternatives considered**:
- ChangeManager class: Rejected - over-engineered for 2 functions
- Add to existing command: Rejected - mixes CLI with reusable logic

### Decision 2: Kebab-Case Validation Pattern

**Choice**: Validate names with `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`

Valid: `add-auth`, `refactor-db`, `add-feature-2`, `refactor`
Invalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--auth`

**Why**:
- Filesystem-safe (no special characters)
- URL-safe (for future web UI)
- Consistent with existing change naming in repo

## File Changes

### New Files
- `src/utils/change-utils.ts` - Utility functions
- `src/utils/change-utils.test.ts` - Unit tests

### Modified Files
- None

## Risks / Trade-offs

| Risk | Mitigation |
|------|------------|
| Function might not cover all use cases | Start simple, extend if needed |
| Naming conflicts with future work | Using clear, specific function names |