"""Buduje blok <user_selection> z zaznaczeniami użytkownika.

Każde kliknięcie przychodzi z przeglądarki jako trójka: xpath elementu, hash
ekranu i moment wskazania w sekundach od początku nagrania. Z zadokowaną kulką
jedno nagranie może objąć kilka ekranów karuzeli, więc kliknięcia są grupowane
po hashu — na każdy ekran wychodzi osobny blok z komentarzem USER_CLICKED_HERE
przed każdym wskazanym elementem, a bloki idą w kolejności pierwszego wpisu.

Tą samą trójką jadą też same odwiedziny ekranu: xpath równy SCROLLED_XPATH nie
wskazuje elementu, tylko mówi „w tej sekundzie użytkownik był na tym ekranie".
Przeglądarka wysyła taki wpis na start nagrania i przy każdym przewinięciu
karuzeli, więc agent widzi treść także tych ekranów, o których użytkownik tylko
mówił, i wie, w której sekundzie je oglądał. Ekran bez ani jednego kliknięcia
wychodzi jako <user_view>, ekran ze wskazaniami jako <user_selection>.

Jeśli xpath albo hash nie pasuje do zapisanego widoku, rzuca UserSelectionMismatchError.
Wyjątek: ekran tylko odwiedzony, którego treści już nie ma w magazynie, jest
pomijany z adnotacją — nie ma tam czego wskazać na złym drzewie, a wywalenie
całego promptu kosztowałoby wypowiedź użytkownika.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from html import escape

from lxml import etree
from lxml import html as lxml_html

from lib import last_view

MARKER_LABEL = "USER_CLICKED_HERE"
RECORD_BTN_XPATH = '//*[@id="recordBtn"]'

# Wpis „użytkownik był na tym ekranie” zamiast wskazania elementu. Celowo nie jest
# poprawnym xpathem (prawdziwe zaczynają się od '//', '.' albo './'), więc nie da się
# go pomylić z zaznaczeniem ani przypadkiem trafić nim w element widoku.
SCROLLED_XPATH = "__scrolled__"


def marker_text(seconds: float | None = None) -> str:
    """Treść komentarza znacznika; z momentem wskazania, gdy przeglądarka go podała."""
    if seconds is None:
        return f" {MARKER_LABEL} "
    return f" {MARKER_LABEL} @ {seconds:.1f}s "


def marker_html(seconds: float | None = None) -> str:
    return f"<!--{marker_text(seconds)}-->"


# zgodność wstecz: znacznik bez momentu wskazania
MARKER_TEXT = marker_text()
MARKER_HTML = marker_html()

HEADER_SELECTED = (
    "Użytkownik podczas wypowiadania tego polecenia zaznaczył ten element w interfejsie"
)
HEADER_SELECTED_MANY = (
    "Użytkownik podczas wypowiadania tego polecenia zaznaczył te elementy w interfejsie "
    f"(każdy oznaczony komentarzem {MARKER_LABEL})"
)
HEADER_SELECTED_SCREENS = (
    "Użytkownik podczas wypowiadania tego polecenia zaznaczał elementy na kilku ekranach "
    f"(każdy oznaczony komentarzem {MARKER_LABEL}); bloki idą w kolejności pierwszego kliknięcia"
)
HEADER_VISITED_SCREENS = (
    "Użytkownik podczas wypowiadania tego polecenia przechodził między ekranami, więc jedno "
    "polecenie może dotyczyć kilku z nich; bloki idą w kolejności odwiedzenia — <user_selection> "
    f"to ekran, na którym coś wskazał (komentarz {MARKER_LABEL}), <user_view> to ekran, "
    "który tylko widział"
)
HEADER_NO_SELECTION = (
    "Użytkownik podczas wypowiadania tego polecenia widział taki interfejs, "
    "ale niczego w nim nie zaznaczył"
)
TIME_HINT = (
    "liczba w znaczniku (np. @ 3.4s) to moment wskazania w sekundach od początku nagrania, "
    "do zestawienia z treścią wypowiedzi"
)
SEEN_AT_HINT = (
    "atrybut seen_at to sekundy, w których dany ekran pojawił się użytkownikowi na oczach "
    "(ta sama oś czasu co znaczniki i co nagrana wypowiedź)"
)
HASH_HINT = (
    "atrybut hash to identyfikator ekranu — podaj go w present_data(replaces=...), "
    "jeśli polecenie każe zmienić to, co użytkownik już widzi"
)
REPLACED_HINT = (
    "atrybut replaced_by znaczy, że ten ekran został już podmieniony — zmieniaj widok "
    "wskazany w replaced_by, bo ten użytkownik ma teraz przed sobą"
)
SKIPPED_NOTE = (
    "Ekrany, przez które użytkownik przewinął, ale których treści już nie mamy w pamięci "
    "(pominięte, hashe): {hashes}"
)


class UserSelectionMismatchError(Exception):
    """xpath albo hash ekranu od webview nie pasuje do zapisanego widoku."""


@dataclass(frozen=True)
class _Click:
    """Jeden wpis: co wskazano (albo samo bycie na ekranie), gdzie i w której sekundzie."""

    xpath: str
    page_hash: str | None
    seconds: float | None

    @property
    def is_visit(self) -> bool:
        """True dla wpisu „byłem na tym ekranie” — nie ma elementu do oznaczenia."""
        return self.xpath == SCROLLED_XPATH


def _as_list(value) -> list:
    if value is None:
        return []
    if isinstance(value, (str, bytes)):
        return [value]
    return list(value)


def _aligned(values: list, count: int, label: str) -> list:
    """Dociąga listę metadanych do liczby xpathów; rozjazd długości znaczy brak danych.

    Przeglądarka wysyła te listy równolegle, więc rozjazd to błąd klienta. Zgadywanie
    dopasowania mogłoby przypiąć czas albo ekran do niewłaściwego elementu, a to gorsze
    niż brak tej informacji — dlatego metadane lecą wtedy do kosza, nie cały prompt.
    """
    if not values:
        return [None] * count
    if len(values) != count:
        logging.warning(
            "liczba wartości (%s: %d) nie zgadza się z liczbą xpathów (%d) — pomijam je, "
            "żeby nie przypisać ich do niewłaściwego elementu",
            label, len(values), count,
        )
        return [None] * count
    return values


def _parse_seconds(value) -> float | None:
    if value is None or value == "":
        return None
    try:
        seconds = float(value)
    except (TypeError, ValueError):
        logging.warning("nieczytelny moment wskazania: %r", value)
        return None
    if seconds < 0:
        logging.warning("ujemny moment wskazania: %r", value)
        return None
    return seconds


def _normalize_clicks(
    xpath: str | list[str] | None,
    page_hash: str | None,
    xpath_hashes: list[str] | None,
    xpath_times: list[str] | None,
) -> list[_Click]:
    """Kliknięcia w kolejności wskazania: bez pustych xpathów i bez samej kulki.

    Powtórzone wskazanie tego samego elementu zostaje osobną pozycją — powrót do
    niego po kilku sekundach jest informacją, nie duplikatem do zwinięcia.
    """
    raw_xpaths = _as_list(xpath)
    hashes = _aligned(_as_list(xpath_hashes), len(raw_xpaths), "hashe ekranów")
    times = _aligned(_as_list(xpath_times), len(raw_xpaths), "momenty wskazania")

    clicks: list[_Click] = []
    for item, item_hash, moment in zip(raw_xpaths, hashes, times):
        if not item:
            continue
        value = item.strip()
        if not value or value == RECORD_BTN_XPATH:
            continue
        # starsza strona w przeglądarce nie zna xpath_hash — wszystko należy wtedy
        # do jednego ekranu wskazanego wspólnym polem hash
        screen = (item_hash or "").strip() or page_hash
        clicks.append(_Click(value, screen, _parse_seconds(moment)))

    return clicks


def _resolve(tree, xpath: str):
    """Zwraca pierwszy element pasujący do xpatha albo rzuca UserSelectionMismatchError."""
    try:
        result = tree.xpath(xpath)
    except Exception as e:
        raise UserSelectionMismatchError(f"błąd ewaluacji xpath: {e}") from e

    if isinstance(result, list):
        for item in result:
            if isinstance(item, etree._Element):
                return item

    raise UserSelectionMismatchError(
        f"xpath {xpath!r} nie pasuje do żadnego elementu zapisanego widoku"
    )


def _screen_html(page_hash: str | None, required: bool) -> str | None:
    """HTML ekranu z magazynu.

    Podany hash musi być w magazynie, gdy na tym ekranie coś wskazano (`required`):
    przy zaznaczeniach z kilku ekranów podstawienie innego widoku wstawiłoby znaczniki
    w nie to drzewo. Ekran tylko odwiedzony wolno pominąć — nie ma tam czego przypiąć.
    """
    if page_hash:
        html = last_view.get_html(page_hash)
        if html is None and required:
            raise UserSelectionMismatchError(
                f"ekran o hashu {page_hash!r} nie jest już zapisany — "
                "nie da się odtworzyć zaznaczenia"
            )
        return html

    html = last_view.get_last_html()
    if html is None and required:
        raise UserSelectionMismatchError(
            "brak zapisanego widoku — present_data nie zostało wcześniej wywołane"
        )
    return html


def _attrs(page_hash: str | None, visits: list[_Click]) -> str:
    """Atrybuty bloku ekranu: jego identyfikator, momenty odwiedzin i ewentualna podmianka."""
    parts: list[str] = []

    if page_hash:
        parts.append(f' hash="{escape(page_hash, quote=True)}"')

    moments = [visit.seconds for visit in visits if visit.seconds is not None]
    if moments:
        seen_at = ", ".join(f"{seconds:.1f}s" for seconds in moments)
        parts.append(f' seen_at="{escape(seen_at, quote=True)}"')

    # widok mógł zostać podmieniony między nagraniem a transkrypcją; agent musi zmieniać
    # ten, który użytkownik ma teraz przed sobą, nie ten z nagrania
    if page_hash:
        successor = last_view.replaced_by(page_hash)
        if successor:
            parts.append(f' replaced_by="{escape(successor, quote=True)}"')

    return "".join(parts)


def _screen_block(page_hash: str | None, entries: list[_Click]) -> str | None:
    """Widok jednego ekranu ze znacznikami jego kliknięć (None, gdy nie ma go w magazynie)."""
    clicks = [entry for entry in entries if not entry.is_visit]
    visits = [entry for entry in entries if entry.is_visit]

    html = _screen_html(page_hash, required=bool(clicks))
    if html is None:
        return None

    attrs = _attrs(page_hash, visits)

    if not clicks:
        # sam ekran, bez wskazań — kontekst dla wypowiedzi i cel ewentualnej podmianki
        return f"<user_view{attrs}>\n{html}\n</user_view>"

    try:
        tree = lxml_html.fromstring(html)
    except Exception as e:
        raise UserSelectionMismatchError(f"nie udało się sparsować widoku: {e}") from e

    # najpierw rozwiąż wszystkie xpathy — niedopasowanie ma przerwać przed modyfikacją drzewa
    elements = [(_resolve(tree, click.xpath), click.seconds) for click in clicks]

    # znaczniki wstawiane w kolejności kliknięć: przy powtórce wcześniejszy zostaje z przodu
    root_markers: list[str] = []
    for elem, seconds in elements:
        if elem is tree:
            root_markers.append(marker_html(seconds))
            continue
        elem.addprevious(etree.Comment(marker_text(seconds)))

    modified = "".join(root_markers) + lxml_html.tostring(tree, encoding="unicode")

    return f"<user_selection{attrs}>\n{modified}\n</user_selection>"


def _header(
    screen_count: int,
    click_count: int,
    timed: bool,
    visited_only: bool,
    hashed: bool,
    replaced: bool,
) -> str:
    if screen_count > 1:
        header = HEADER_VISITED_SCREENS if visited_only else HEADER_SELECTED_SCREENS
    elif click_count == 0:
        header = HEADER_NO_SELECTION
    elif click_count == 1:
        header = HEADER_SELECTED
    else:
        header = HEADER_SELECTED_MANY

    hints = []
    if timed:
        hints.append(TIME_HINT)
    if visited_only:
        hints.append(SEEN_AT_HINT)
    if hashed:
        hints.append(HASH_HINT)
    if replaced:
        hints.append(REPLACED_HINT)

    if not hints:
        return f"{header}:"

    # przy kilku wskazówkach jedna linia robi się nieczytelna — lecą listą
    if len(hints) == 1:
        return f"{header} — {hints[0]}:"
    return "\n".join([f"{header}:", *(f"- {hint}" for hint in hints)])


def _no_selection(html: str, page_hash: str | None = None) -> str:
    return f"{HEADER_NO_SELECTION}:\n<user_view{_attrs(page_hash, [])}>\n{html}\n</user_view>"


def build_user_selection_block(
    xpath: str | list[str] | None,
    page_hash: str | None = None,
    xpath_hashes: list[str] | None = None,
    xpath_times: list[str] | None = None,
) -> str | None:
    entries = _normalize_clicks(xpath, page_hash, xpath_hashes, xpath_times)

    if not entries:
        # bez żadnego wpisu sam widok jest tylko kontekstem, więc nieznany hash może
        # spaść na ostatni widok — nie ma czego przypiąć do niewłaściwego drzewa
        html = last_view.get_html(page_hash) if page_hash else None
        shown = page_hash if html is not None else None
        if html is None:
            html = last_view.get_last_html()
            shown = last_view.get_last_hash()
        return _no_selection(html, shown) if html else None

    # dict trzyma kolejność wstawiania, więc ekrany wychodzą w kolejności pierwszego wpisu
    screens: dict[str | None, list[_Click]] = {}
    for entry in entries:
        screens.setdefault(entry.page_hash, []).append(entry)

    blocks: list[str] = []
    skipped: list[str] = []
    visited_only = False

    for screen, items in screens.items():
        block = _screen_block(screen, items)
        if block is None:
            # ekran tylko przewinięty, którego treści już nie mamy — mówimy o tym wprost,
            # żeby agent nie uznał, że widzi wszystko, przez co użytkownik przechodził
            skipped.append(screen or "?")
            continue
        blocks.append(block)
        if all(item.is_visit for item in items):
            visited_only = True

    note = [SKIPPED_NOTE.format(hashes=", ".join(skipped))] if skipped else []

    if not blocks:
        logging.warning("żaden z odwiedzonych ekranów nie jest już zapisany: %s", skipped)
        return "\n".join(note) or None

    clicks = [entry for entry in entries if not entry.is_visit]
    joined = "".join(blocks)

    header = _header(
        screen_count=len(blocks),
        click_count=len(clicks),
        timed=any(click.seconds is not None for click in clicks),
        visited_only=visited_only,
        hashed='hash="' in joined,
        replaced='replaced_by="' in joined,
    )
    return "\n".join([header, *blocks, *note])
