import json, os, time, threading, re, socket, ssl, base64, hashlib, struct, subprocess, tempfile, shutil, secrets, html
from urllib.request import Request, urlopen
from urllib.parse import urlencode, urljoin
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler

BASE=os.path.dirname(os.path.abspath(__file__))
API="https://system.szps.pl/webapi.php"
CONFIG=os.path.join(BASE,"config.json")
OVERLAY_CONTROL=os.path.join(BASE,"overlay_control.json")
lock=threading.Lock()
resolved={}
vs_cache={}
vs_token_cache={"token":None}
league_cache={"body":None,"created":0,"error":None,"refreshing":False,"matchId":None}
squads_cache={"body":None,"created":0,"error":None,"refreshing":False,"matchId":None}
league_cache_lock=threading.Lock()
squads_cache_lock=threading.Lock()
schedule_cache={"body":None,"created":0,"error":None,"refreshing":False}
schedule_cache_lock=threading.Lock()
venue_cache={}
venue_cache_lock=threading.Lock()
match_time_cache={}
match_time_lock=threading.Lock()
match_page_cache={}
match_page_lock=threading.Lock()

def get_text(url, timeout=25):
    req=Request(url,headers={
        "User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/151 Safari/537.36"
    })
    with urlopen(req,timeout=timeout) as r:
        return r.read().decode("utf-8-sig","replace")

def get_json(url):
    return json.loads(get_text(url))

def norm(s): return " ".join(str(s or "").split())

def config():
    with open(CONFIG,encoding="utf-8") as f: return json.load(f)


def _default_shirt_colors():
    return {
        "homePrimary":"#d53c3c",
        "homeSecondary":"#9e1e1e",
        "awayPrimary":"#2b62cf",
        "awaySecondary":"#1f438f"
    }

def get_shirt_colors():
    c=config().get("shirtColors") or {}
    d=_default_shirt_colors()
    out={}
    for k,v in d.items():
        x=str(c.get(k) or v).strip()
        if not re.fullmatch(r"#[0-9A-Fa-f]{6}", x):
            x=v
        out[k]=x
    return out

def club_id(name, clubs):
    n=norm(name).lower()
    for c in clubs:
        if norm(c.get("name")).lower()==n: return c.get("club_id")
    for c in clubs:
        cn=norm(c.get("name")).lower()
        if n in cn or cn in n: return c.get("club_id")
    return None

def _match_teams_from_page(mid):
    """
    Fast resolver for any length of SZPS match id (3, 4, 5... digits).
    The public match page title contains both team names, e.g.
    "Team A vs Team B - 22.09.2026 - SZPS".
    """
    page=get_text("https://system.szps.pl/mecz/"+str(mid)+"/")
    tm=re.search(r"<title[^>]*>(.*?)</title>",page,re.I|re.S)
    if not tm:
        return None,None
    title=html.unescape(re.sub(r"<[^>]+>","",tm.group(1))).strip()
    mm=re.match(r"^(.*?)\s+vs\s+(.*?)\s+-\s+\d{2}\.\d{2}\.\d{4}\s+-\s+SZPS",title,re.I)
    if not mm:
        return None,None
    return norm(mm.group(1)),norm(mm.group(2))

def _cache_resolved(mid,m,clubs):
    resolved.clear()
    resolved.update({
        "mid":str(mid),
        "match":m,
        "clubs":clubs,
        "home_id":club_id(m.get("home"),clubs),
        "away_id":club_id(m.get("visitor"),clubs)
    })
    return resolved

def resolve(mid):
    """
    Resolve a SZPS match id. v13 first uses the direct /mecz/{id}/ page,
    so a 4-digit id does not require scanning every club in the database.
    """
    mid=str(mid).strip()
    if not mid.isdigit():
        raise RuntimeError("Numer meczu SZPS musi składać się wyłącznie z cyfr.")

    with lock:
        if resolved.get("mid")==mid:
            return resolved

        clubs=get_json(API+"?clubs").get("clubs",[])

        # FAST PATH: direct match page -> team names -> only those club endpoints.
        home_name,away_name=_match_teams_from_page(mid)
        candidate_ids=[]
        for name in (home_name,away_name):
            if name:
                cid=club_id(name,clubs)
                if cid is not None and cid not in candidate_ids:
                    candidate_ids.append(cid)

        for cid in candidate_ids:
            try:
                matches=get_json(API+"?club="+str(cid)).get("matches",[])
            except Exception:
                continue
            for m in matches:
                if str(m.get("match_id"))==mid:
                    return _cache_resolved(mid,m,clubs)

        # FALLBACK: preserve the old resolver in case a page title is unusual.
        seen=set(candidate_ids)
        for c in clubs:
            cid=c.get("club_id")
            if cid in seen:
                continue
            seen.add(cid)
            try:
                matches=get_json(API+"?club="+str(cid)).get("matches",[])
            except Exception:
                continue
            for m in matches:
                if str(m.get("match_id"))==mid:
                    return _cache_resolved(mid,m,clubs)

        raise RuntimeError(
            "Nie znaleziono meczu SZPS nr "+mid+
            ". Sprawdź, czy jest to ID z adresu /mecz/"+mid+"/, a nie numer kolejki/meczu w grupie."
        )

def pick_table(payload,m):
    comp=norm(m.get("competition_name")); phase=str(m.get("phase")); group=norm(m.get("group_name"))
    tables=payload.get("tables",[])
    for t in tables:
        if norm(t.get("competition"))==comp and str(t.get("phase"))==phase and norm(t.get("group_name"))==group:
            return t
    for t in tables:
        if norm(t.get("competition"))==comp and str(t.get("phase"))==phase:
            return t
    return {}

def _to_int(v, default=0):
    try:
        return int(str(v))
    except Exception:
        return default

def _clean_match_html_value(raw):
    raw=re.sub(r"(?i)<br\s*/?>","\n",str(raw or ""))
    raw=re.sub(r"<[^>]+>"," ",raw)
    raw=html.unescape(raw)
    parts=[norm(x) for x in raw.splitlines() if norm(x)]
    return " • ".join(parts)

def get_match_page_details(mid):
    """
    Read details directly from the public SZPS page /mecz/{match_id}/.

    This is the primary source for schedule time and physical venue because SZPS
    exposes them per individual fixture as:
      - "Mecz rozgrywany w:"
      - "godzina meczu:"

    One page fetch supplies both values and is cached.
    """
    key=str(mid or "").strip()
    if not key:
        return {"time":"","venue":""}

    with match_page_lock:
        if key in match_page_cache:
            return dict(match_page_cache[key])

    result={"time":"","venue":""}
    try:
        page=get_text("https://system.szps.pl/mecz/"+key+"/")

        vm=re.search(
            r"Mecz\s+rozgrywany\s+w:\s*</h5>.*?<h5[^>]*>(.*?)</h5>",
            page,re.I|re.S
        )
        if vm:
            result["venue"]=_clean_match_html_value(vm.group(1))

        tm=re.search(
            r"godzina\s+meczu:\s*</h5>.*?<h5[^>]*>(.*?)</h5>",
            page,re.I|re.S
        )
        if tm:
            raw=_clean_match_html_value(tm.group(1))
            hh=re.search(r"\b([0-2]?\d:[0-5]\d)\b",raw)
            result["time"]=hh.group(1) if hh else ""
    except Exception:
        pass

    with match_page_lock:
        match_page_cache[key]=dict(result)
    with match_time_lock:
        match_time_cache[key]=result["time"]

    return result

def get_match_time(mid):
    key=str(mid)
    with match_time_lock:
        if key in match_time_cache:
            return match_time_cache[key]
    return get_match_page_details(mid).get("time") or ""

def _time_minutes(t):
    if not t:
        return None
    try:
        hh,mm=t.split(":")
        return int(hh)*60+int(mm)
    except Exception:
        return None

def _same_day_relation(candidate,current):
    """
    Compare two matches on the same calendar date.
    Exact start time wins. If SZPS does not publish a time, unique match_id is
    used as a stable fallback. We deliberately do NOT use the displayed
    'Mecz numer', because in Młodziczki several matches in the same tournament
    day can share/reuse that number.
    """
    ct=get_match_time(current.get("match_id"))
    xt=get_match_time(candidate.get("match_id"))
    cm=_time_minutes(ct)
    xm=_time_minutes(xt)

    if cm is not None and xm is not None and xm != cm:
        return -1 if xm < cm else 1

    cid=_to_int(current.get("match_id"))
    xid=_to_int(candidate.get("match_id"))
    if xid < cid:
        return -1
    if xid > cid:
        return 1
    return 0

def _decorate_match_time(x):
    y=dict(x)
    y["match_time"]=get_match_time(x.get("match_id"))
    return y

def old_matches(payload,m,limit,team_name=None):
    out=[]; current=str(m.get("match_id"))
    comp=norm(m.get("competition_name")); phase=str(m.get("phase")); group=norm(m.get("group_name"))
    current_date=str(m.get("match_date") or "")

    for x in payload.get("matches",[]):
        if str(x.get("match_id"))==current:
            continue
        if norm(x.get("competition_name"))!=comp or str(x.get("phase"))!=phase or norm(x.get("group_name"))!=group:
            continue
        if team_name and not _match_contains_exact_team(team_name, x):
            continue

        xd=str(x.get("match_date") or "")
        if current_date and xd:
            if xd > current_date:
                continue
            if xd == current_date and _same_day_relation(x,m) >= 0:
                continue

        out.append(_decorate_match_time(x))

    def key(x):
        mins=_time_minutes(x.get("match_time"))
        return (
            str(x.get("match_date") or ""),
            -1 if mins is None else mins,
            _to_int(x.get("match_id"))
        )

    out.sort(key=key,reverse=True)
    return out[:limit]


def next_match(payload,m,team_name=None):
    """
    Find the next match for a specific exact team name inside the same competition/phase/group.
    """
    current=str(m.get("match_id"))
    comp=norm(m.get("competition_name"))
    phase=str(m.get("phase"))
    group=norm(m.get("group_name"))
    current_date=str(m.get("match_date") or "")
    out=[]

    for x in payload.get("matches",[]):
        if str(x.get("match_id"))==current:
            continue
        if norm(x.get("competition_name"))!=comp or str(x.get("phase"))!=phase or norm(x.get("group_name"))!=group:
            continue
        if team_name and not _match_contains_exact_team(team_name, x):
            continue

        xd=str(x.get("match_date") or "")
        if current_date and xd:
            if xd < current_date:
                continue
            if xd == current_date and _same_day_relation(x,m) <= 0:
                continue

        out.append(_decorate_match_time(x))

    def key(x):
        mins=_time_minutes(x.get("match_time"))
        return (
            str(x.get("match_date") or ""),
            9999 if mins is None else mins,
            _to_int(x.get("match_id"))
        )

    out.sort(key=key)
    return out[0] if out else None


# ---------------- VolleyStation ----------------

def get_volleystation_info(szps_match_id):
    page=get_text("https://system.szps.pl/mecz/"+str(szps_match_id)+"/")
    m=re.search(r'https://widgets\.volleystation\.com/play-by-play/(\d+)([^"\'<>\s]*)',page,re.I)
    if not m:
        # fallback - at least recover the numeric VolleyStation id
        m2=re.search(r'widgets\.volleystation\.com/(?:court|scoreboard|play-by-play)/(\d+)',page,re.I)
        if not m2:
            raise RuntimeError("Dla tego meczu nie znaleziono ID VolleyStation na stronie SZPS.")
        vs_id=int(m2.group(1))
        return vs_id, "https://widgets.volleystation.com/play-by-play/"+str(vs_id)
    vs_id=int(m.group(1))
    suffix=(m.group(2) or "").replace("&amp;","&")
    return vs_id, "https://widgets.volleystation.com/play-by-play/"+str(vs_id)+suffix

def get_volleystation_id(szps_match_id):
    return get_volleystation_info(szps_match_id)[0]

def find_chromium_browser():
    candidates=[]
    pf=os.environ.get("PROGRAMFILES","")
    pf86=os.environ.get("PROGRAMFILES(X86)","")
    local=os.environ.get("LOCALAPPDATA","")
    if pf:
        candidates += [
            os.path.join(pf,"Google","Chrome","Application","chrome.exe"),
            os.path.join(pf,"Microsoft","Edge","Application","msedge.exe"),
            os.path.join(pf,"Chromium","Application","chrome.exe"),
        ]
    if pf86:
        candidates += [
            os.path.join(pf86,"Google","Chrome","Application","chrome.exe"),
            os.path.join(pf86,"Microsoft","Edge","Application","msedge.exe"),
        ]
    if local:
        candidates += [
            os.path.join(local,"Google","Chrome","Application","chrome.exe"),
            os.path.join(local,"Microsoft","Edge","Application","msedge.exe"),
        ]
    for p in candidates:
        if p and os.path.isfile(p):
            return p
    # Last chance: executable in PATH.
    for exe in ("chrome.exe","msedge.exe","chrome","chromium","microsoft-edge"):
        p=shutil.which(exe)
        if p:
            return p
    raise RuntimeError(
        "Nie znaleziono Google Chrome ani Microsoft Edge. "
        "v9 używa przeglądarki do pobierania danych VolleyStation."
    )

def _free_local_port():
    s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
    s.bind(("127.0.0.1",0))
    port=s.getsockname()[1]
    s.close()
    return port

def _cdp_ws_connect(url):
    from urllib.parse import urlparse
    u=urlparse(url)
    host=u.hostname or "127.0.0.1"
    port=u.port or 80
    path=u.path or "/"
    if u.query:
        path += "?" + u.query
    sock=socket.create_connection((host,port),timeout=10)
    key=base64.b64encode(os.urandom(16)).decode()
    req=(
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        "Upgrade: websocket\r\n"
        "Connection: Upgrade\r\n"
        f"Sec-WebSocket-Key: {key}\r\n"
        "Sec-WebSocket-Version: 13\r\n"
        "\r\n"
    )
    sock.sendall(req.encode("ascii"))
    header=b""
    while b"\r\n\r\n" not in header:
        part=sock.recv(4096)
        if not part:
            raise RuntimeError("Chrome zamknął lokalne połączenie DevTools.")
        header+=part
        if len(header)>65536:
            raise RuntimeError("Nieprawidłowy handshake Chrome DevTools.")
    if b" 101 " not in header.split(b"\r\n",1)[0]:
        raise RuntimeError("Chrome DevTools odrzucił połączenie.")
    sock.settimeout(2)
    return sock

def _cdp_send(sock,obj):
    payload=json.dumps(obj,separators=(",",":")).encode("utf-8")
    first=0x81
    ln=len(payload)
    if ln<126:
        header=bytes([first,0x80|ln])
    elif ln<65536:
        header=bytes([first,0x80|126])+struct.pack("!H",ln)
    else:
        header=bytes([first,0x80|127])+struct.pack("!Q",ln)
    mask=os.urandom(4)
    masked=bytes(b ^ mask[i%4] for i,b in enumerate(payload))
    sock.sendall(header+mask+masked)

def _cdp_read_exact(sock,n):
    data=b""
    while len(data)<n:
        part=sock.recv(n-len(data))
        if not part:
            raise RuntimeError("Chrome zamknął DevTools WebSocket.")
        data+=part
    return data

def _cdp_recv(sock):
    fragments=[]
    active=False
    while True:
        b1,b2=_cdp_read_exact(sock,2)
        fin=bool(b1&0x80)
        opcode=b1&0x0f
        ln=b2&0x7f
        if ln==126:
            ln=struct.unpack("!H",_cdp_read_exact(sock,2))[0]
        elif ln==127:
            ln=struct.unpack("!Q",_cdp_read_exact(sock,8))[0]
        masked=bool(b2&0x80)
        mask=_cdp_read_exact(sock,4) if masked else None
        payload=_cdp_read_exact(sock,ln) if ln else b""
        if masked:
            payload=bytes(b ^ mask[i%4] for i,b in enumerate(payload))
        if opcode==8:
            raise RuntimeError("Chrome zakończył sesję DevTools.")
        if opcode==9:
            # Pong
            pong=b"\x8a"
            l=len(payload)
            if l<126:
                hdr=pong+bytes([0x80|l])
            elif l<65536:
                hdr=pong+bytes([0x80|126])+struct.pack("!H",l)
            else:
                hdr=pong+bytes([0x80|127])+struct.pack("!Q",l)
            m=os.urandom(4)
            sock.sendall(hdr+m+bytes(b ^ m[i%4] for i,b in enumerate(payload)))
            continue
        if opcode==10:
            continue
        if opcode==1:
            fragments=[payload]
            active=True
        elif opcode==0 and active:
            fragments.append(payload)
        else:
            continue
        if fin:
            return b"".join(fragments).decode("utf-8","replace")

def fetch_volleystation(vs_id, widget_url=None):
    """
    Use a real non-headless Chrome/Edge process. Cloudflare accepted the user's
    normal browser in the HAR (101 Switching Protocols), while headless Chrome
    can be classified differently and receive no widget data.
    """
    browser=find_chromium_browser()
    port=_free_local_port()
    profile=tempfile.mkdtemp(prefix="szps_vs_")
    proc=None
    ws=None
    ws_status=[]
    ws_created=[]
    if not widget_url:
        widget_url=f"https://widgets.volleystation.com/play-by-play/{int(vs_id)}"

    try:
        args=[
            browser,
            f"--remote-debugging-port={port}",
            "--remote-allow-origins=*",
            f"--user-data-dir={profile}",
            "--no-first-run",
            "--no-default-browser-check",
            "--disable-background-networking",
            "--disable-component-update",
            "--disable-default-apps",
            "--disable-sync",
            "--disable-extensions",
            "--disable-features=TranslateUI",
            "--window-size=900,700",
            "--window-position=-32000,-32000",
            "--start-minimized",
            "about:blank",
        ]
        proc=subprocess.Popen(
            args,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            creationflags=(0x08000000 if os.name=="nt" else 0)
        )

        targets=None
        for _ in range(75):
            try:
                targets=get_json(f"http://127.0.0.1:{port}/json")
                if targets:
                    break
            except Exception:
                pass
            time.sleep(0.2)
        if not targets:
            raise RuntimeError("Nie udało się uruchomić lokalnej sesji Chrome/Edge.")

        page=next((x for x in targets if x.get("type")=="page" and x.get("webSocketDebuggerUrl")),None)
        if not page:
            raise RuntimeError("Chrome/Edge nie udostępnił karty DevTools.")

        ws=_cdp_ws_connect(page["webSocketDebuggerUrl"])
        cmd=1
        _cdp_send(ws,{"id":cmd,"method":"Network.enable"}); cmd+=1
        _cdp_send(ws,{"id":cmd,"method":"Page.enable"}); cmd+=1
        _cdp_send(ws,{"id":cmd,"method":"Runtime.enable"}); cmd+=1

        _cdp_send(ws,{
            "id":cmd,
            "method":"Page.navigate",
            "params":{"url":widget_url}
        })
        cmd+=1

        assets={}
        match_doc=None
        deadline=time.time()+35

        while time.time()<deadline:
            try:
                raw=_cdp_recv(ws)
            except socket.timeout:
                continue
            try:
                evt=json.loads(raw)
            except Exception:
                continue

            method=evt.get("method")
            params=evt.get("params") or {}

            if method=="Network.webSocketCreated":
                url=params.get("url","")
                if "api.widgets.volleystation.com" in url:
                    ws_created.append(url)

            elif method=="Network.webSocketHandshakeResponseReceived":
                resp=params.get("response") or {}
                url=resp.get("url","")
                if "api.widgets.volleystation.com" in url:
                    ws_status.append(str(resp.get("status","")))

            elif method=="Network.webSocketFrameReceived":
                response=params.get("response") or {}
                payload=response.get("payloadData","")
                if not isinstance(payload,str):
                    continue

                # widget/assets
                if payload.startswith("430"):
                    try:
                        a=json.loads(payload[3:])
                        if len(a)>1 and isinstance(a[1],dict):
                            assets=a[1]
                    except Exception:
                        pass

                # HAR shows this response already contains the complete teams,
                # scout.sets and startingLineup arrays. We do not need to wait
                # for the subsequent "get" response.
                elif payload.startswith("431"):
                    try:
                        a=json.loads(payload[3:])
                        result=a[1] if len(a)>1 and isinstance(a[1],dict) else {}
                        data=result.get("data",[])
                        if data and isinstance(data[0],dict):
                            match_doc=data[0]
                            break
                    except Exception:
                        pass

                elif payload.startswith("432"):
                    try:
                        a=json.loads(payload[3:])
                        if len(a)>1 and isinstance(a[1],dict):
                            match_doc=a[1]
                            break
                    except Exception:
                        pass

        if not isinstance(match_doc,dict):
            detail=[]
            if ws_created:
                detail.append("WebSocket utworzony")
            if ws_status:
                detail.append("status handshake: "+",".join(ws_status))
            if not detail:
                detail.append("brak połączenia WebSocket widgetu")
            raise RuntimeError(
                "Chrome/Edge otworzył VolleyStation, ale nie odebrano danych historii spotkania "
                "(" + "; ".join(detail) + ")."
            )
        return match_doc, assets

    finally:
        if ws:
            try: ws.close()
            except Exception: pass
        if proc:
            try:
                proc.terminate()
                proc.wait(timeout=3)
            except Exception:
                try: proc.kill()
                except Exception: pass
        try:
            shutil.rmtree(profile,ignore_errors=True)
        except Exception:
            pass

def _coach(team):
    for s in team.get("staff",[]) or []:
        if s.get("type")=="coach":
            p=s.get("person",{}) or {}
            return norm((p.get("firstName") or "")+" "+(p.get("lastName") or ""))
    return ""

def compact_vs(szps_match_id, lineup_set):
    cache_key=str(szps_match_id)
    with lock:
        cached=vs_cache.get(cache_key)
    if not cached:
        vsid,widget_url=get_volleystation_info(szps_match_id)
        doc,assets=fetch_volleystation(vsid,widget_url)
        cached={"id":vsid,"doc":doc,"assets":assets}
        with lock:
            vs_cache.clear()
            vs_cache[cache_key]=cached

    doc=cached["doc"]; assets=cached["assets"]; vsid=cached["id"]
    sets=((doc.get("scout") or {}).get("sets") or [])
    requested=max(1,int(lineup_set or 1))
    idx=requested-1
    if idx>=len(sets) or not (sets[idx].get("startingLineup") if idx<len(sets) else None):
        idx=next((i for i,s in enumerate(sets) if s.get("startingLineup")),0)
    selected=sets[idx] if sets else {}
    lineup=selected.get("startingLineup") or {"home":[],"away":[]}

    def compact_team(side):
        t=(doc.get("teams") or {}).get(side) or {}
        libero=set(t.get("libero") or [])
        captain=t.get("captain")
        players=[]
        for p in t.get("players",[]) or []:
            num=p.get("shirtNumber")
            players.append({
                "code":p.get("code"),
                "number":num,
                "firstName":p.get("firstName") or "",
                "lastName":p.get("lastName") or "",
                "name":norm((p.get("firstName") or "")+" "+(p.get("lastName") or "")),
                "captain":num==captain,
                "libero":num in libero
            })
        players.sort(key=lambda x:(9999 if x["number"] is None else x["number"],x["lastName"]))
        return {
            "name":t.get("name") or "",
            "shortName":t.get("shortName") or "",
            "coach":_coach(t),
            "captain":captain,
            "libero":list(libero),
            "players":players,
            "startingLineup":lineup.get(side) or []
        }

    return {
        "id":vsid,
        "source":"VolleyStation play-by-play / historia spotkania",
        "set":idx+1,
        "setScore":selected.get("score") or {},
        "rotationZones":((doc.get("settings") or {}).get("rotationZones") or [1,6,5,4,3,2]),
        "courtImage":"https://widgets.volleystation.com/assets/svg/court.svg",
        "home":compact_team("home"),
        "away":compact_team("away"),
        "assets":{
            "homeImage":assets.get("home_image"),
            "awayImage":assets.get("away_image"),
            "homePlayerPhotos":assets.get("home_player_photos") or {},
            "awayPlayerPhotos":assets.get("away_player_photos") or {}
        }
    }

def _match_payload(m):
    return {
        "id":str(m.get("match_id")),
        "home":norm(m.get("home")),
        "away":norm(m.get("visitor")),
        "date":m.get("match_date"),
        "competition":norm(m.get("competition_name")),
        "phase":m.get("phase"),
        "group":norm(m.get("group_name")),
        "score":m.get("score")
    }


def _same_group_match(x, m):
    return (
        norm(x.get("competition_name")) == norm(m.get("competition_name"))
        and str(x.get("phase")) == str(m.get("phase"))
        and norm(x.get("group_name")) == norm(m.get("group_name"))
    )

def _score_pair(score):
    text=re.sub(r"<[^>]*>", "", str(score or "")).strip()
    mm=re.search(r"(\d+)\s*:\s*(\d+)", text)
    if not mm:
        return None
    return int(mm.group(1)), int(mm.group(2))

def _has_team_name(v):
    return bool(norm(v))

def _match_has_both_teams(x):
    return _has_team_name(x.get("home")) and _has_team_name(x.get("visitor"))


def _match_contains_exact_team(team_name, x):
    team=norm(team_name)
    return team and (team == norm(x.get("home")) or team == norm(x.get("visitor")))

def _team_result_for_match(team_name, x):
    """
    Returns:
      "W" = team won
      "L" = team lost
      None = unplayed / missing score / bye / team mismatch
    """
    if not _match_has_both_teams(x):
        return None
    pair=_score_pair(x.get("score"))
    if not pair:
        return None
    home=norm(x.get("home"))
    away=norm(x.get("visitor"))
    team=norm(team_name)
    if team == home:
        return "W" if pair[0] > pair[1] else "L"
    if team == away:
        return "W" if pair[1] > pair[0] else "L"
    return None

def _recent_form_for_team(team_name, club_payload, current_match, limit=4):
    arr=[]
    for x in club_payload.get("matches",[]) or []:
        if not _same_group_match(x, current_match):
            continue
        res=_team_result_for_match(team_name, x)
        if not res:
            continue
        arr.append(_decorate_match_time(x))

    def key(x):
        mins=_time_minutes(x.get("match_time"))
        return (
            str(x.get("match_date") or ""),
            -1 if mins is None else mins,
            _to_int(x.get("match_id"))
        )

    arr.sort(key=key, reverse=True)
    out=[]
    seen=set()
    for x in arr:
        mid=str(x.get("match_id"))
        if mid in seen:
            continue
        seen.add(mid)
        out.append(_team_result_for_match(team_name, x))
        if len(out) >= limit:
            break
    return out

def _enrich_table_with_form(rows, clubs, current_match):
    """
    Adds row["form"] = ["W","L",...] for the last 4 played matches.
    Fetches each club only once.
    """
    cache={}
    out=[]
    for row in rows or []:
        team_name=norm(row.get("team"))
        cid=club_id(team_name, clubs)
        form=[]
        if cid is not None:
            if cid not in cache:
                try:
                    cache[cid]=get_json(API+"?club="+str(cid))
                except Exception:
                    cache[cid]={"matches":[]}
            form=_recent_form_for_team(team_name, cache[cid], current_match, 4)
        new_row=dict(row)
        new_row["form"]=form
        out.append(new_row)
    return out

def build_league(match_id=None):
    """
    TYLKO SZPS.
    Ta ścieżka nie dotyka VolleyStation, więc tabela i historia spotkań
    działają także dla meczu, który jeszcze się nie odbył.
    """
    cfg=config()
    mid=match_id if match_id is not None else cfg["matchId"]
    r=resolve(mid)
    m=r["match"]

    if not r.get("home_id") or not r.get("away_id"):
        raise RuntimeError("Nie udało się ustalić ID klubów.")

    hm=get_json(API+"?club="+str(r["home_id"]))
    am=get_json(API+"?club="+str(r["away_id"]))
    hs=get_json(API+"?standings&club="+str(r["home_id"]))
    table=pick_table(hs,m)
    if not table:
        table=pick_table(get_json(API+"?standings&club="+str(r["away_id"])),m)

    base_table=table.get("full_standings",[]) or table.get("standings",[])
    rich_table=_enrich_table_with_form(base_table, r["clubs"], m)

    limit=int(cfg.get("previousMatchesLimit",4))
    return {
        "match":_match_payload(m),
        "table":rich_table,
        "previousMatches":{
            "home":old_matches(hm,m,limit,m.get("home")),
            "away":old_matches(am,m,limit,m.get("visitor"))
        },
        "nextMatches":{
            "home":next_match(hm,m,m.get("home")),
            "away":next_match(am,m,m.get("visitor"))
        },
        "refreshSeconds":int(cfg.get("refreshSeconds",15)),
        "generatedAt":time.strftime("%Y-%m-%d %H:%M:%S")
    }

def build_squads(match_id=None, lineup_set=None):
    """
    TYLKO składy / VolleyStation.
    Brak rozegranego meczu lub brak historii VolleyStation NIE wpływa na tabelę.
    Zwracamy wtedy available=False zamiast rzucać błędem całej aplikacji.
    """
    cfg=config()
    mid=match_id if match_id is not None else cfg["matchId"]
    requested_set=lineup_set if lineup_set is not None else cfg.get("lineupSet",1)

    r=resolve(mid)
    m=r["match"]
    match_payload=_match_payload(m)

    try:
        vs=compact_vs(mid,requested_set)
        return {
            "match":match_payload,
            "available":True,
            "volleyStation":vs,
            "appearance":{"shirtColors":get_shirt_colors()},
            "volleyStationError":None,
            "generatedAt":time.strftime("%Y-%m-%d %H:%M:%S")
        }
    except Exception as e:
        return {
            "match":match_payload,
            "available":False,
            "volleyStation":None,
            "appearance":{"shirtColors":get_shirt_colors()},
            "volleyStationError":str(e),
            "message":"Składy nie są jeszcze dostępne w VolleyStation dla tego meczu.",
            "generatedAt":time.strftime("%Y-%m-%d %H:%M:%S")
        }


MUKS_CLUB_ID=80
MUKS_NAME="MUKS Michałkowice"

def _strip_html_block(raw):
    raw=re.sub(r"(?i)<br\s*/?>","\n",raw)
    raw=re.sub(r"(?i)</(?:p|div|li|tr|h\d)>","\n",raw)
    raw=re.sub(r"<[^>]+>"," ",raw)
    raw=html.unescape(raw)
    lines=[]
    for line in raw.splitlines():
        line=norm(line)
        if line:
            lines.append(line)
    return lines

def _plain_key(v):
    x=str(v or "").lower()
    x=x.replace("ł","l")
    import unicodedata
    x="".join(c for c in unicodedata.normalize("NFD",x) if unicodedata.category(c)!="Mn")
    return " ".join(x.split())

def _category_name(m):
    return norm(
        m.get("competition_name")
        or m.get("age_name")
        or m.get("category")
        or m.get("competition")
        or "Rozgrywki SZPS"
    )

def _find_team_id_for_venue(club_id_value, team_name, category):
    try:
        page=get_text("https://system.szps.pl/index.php?mode=8&club_id="+str(club_id_value))
    except Exception:
        return None

    team_key=_plain_key(team_name)
    category_key=_plain_key(category)
    candidates=[]
    for mm in re.finditer(
        r'<a[^>]+href=["\'][^"\']*team_id=(\d+)[^"\']*["\'][^>]*>(.*?)</a>',
        page,re.I|re.S
    ):
        tid=mm.group(1)
        label=norm(html.unescape(re.sub(r"<[^>]+>"," ",mm.group(2))))
        lk=_plain_key(label)
        score=0
        if team_key and lk.startswith(team_key):
            score+=10
        if team_key and team_key in lk:
            score+=5
        if category_key and category_key in lk:
            score+=6
        # Protect team II/III distinctions where possible.
        if team_key.endswith(" ii") and " ii " in (" "+lk+" "):
            score+=4
        if team_key.endswith(" iii") and " iii " in (" "+lk+" "):
            score+=4
        candidates.append((score,tid,label))
    if not candidates:
        return None
    candidates.sort(reverse=True)
    return candidates[0][1] if candidates[0][0] > 0 else None

def _venue_for_home_team(team_name, category, clubs):
    """
    Resolve the home team's declared venue from the SZPS club/team page.
    Returns {text, address, hall}; falls back to a descriptive away/home label.
    """
    cache_key=_plain_key(team_name)+"|"+_plain_key(category)
    with venue_cache_lock:
        if cache_key in venue_cache:
            return venue_cache[cache_key]

    result={"text":"","address":"","hall":""}
    try:
        cid=club_id(team_name,clubs)
        if cid is not None:
            tid=_find_team_id_for_venue(cid,team_name,category)
            if tid:
                page=get_text(
                    "https://system.szps.pl/index.php?mode=8&club_id="
                    +str(cid)+"&team_id="+str(tid)
                )
                mm=re.search(
                    r"Zespół\s+rozgrywa\s+swoje\s+mecze\s+w\s*:(.*?)(?:Zazwyczaj\s+o\s+godzinie|Trenerzy\s+zespołu|Lista\s+meczów)",
                    page,re.I|re.S
                )
                if mm:
                    lines=_strip_html_block(mm.group(1))
                    # Normally: address first, hall second. Avoid long organiser notes.
                    address=lines[0] if len(lines)>=1 else ""
                    hall=lines[1] if len(lines)>=2 else ""
                    result={
                        "address":address,
                        "hall":hall,
                        "text":" • ".join(x for x in (hall,address) if x)
                    }
    except Exception:
        pass

    with venue_cache_lock:
        venue_cache[cache_key]=result
    return result

def _is_muks_team(name):
    k=_plain_key(name)
    base=_plain_key(MUKS_NAME)
    return k==base or k.startswith(base+" ")


def _is_siemianowice_venue(venue):
    text=" ".join([
        str((venue or {}).get("text") or ""),
        str((venue or {}).get("address") or ""),
        str((venue or {}).get("hall") or "")
    ])
    key=_plain_key(text)
    return "siemianowic" in key

def _is_pause_name(name):
    return _plain_key(name) in ("","pauzuje","pauza")

def _match_is_future_or_unplayed(m, today):
    ds=str(m.get("match_date") or "").strip()
    if not re.fullmatch(r"\d{4}-\d{2}-\d{2}",ds):
        return False
    if ds < today:
        return False
    if ds > today:
        return True
    # Today: retain only not-yet-scored matches.
    return _score_pair(m.get("score")) is None


def _tournament_key(m):
    """
    Tournament-day grouping key for Młodziczki/Młodzicy:
    same date + competition/category + phase + group.
    Fixtures in such a key share the same host venue even if the 'home' team
    differs between individual matches.
    """
    return (
        str(m.get("match_date") or ""),
        _plain_key(_category_name(m)),
        _plain_key(m.get("phase")),
        _plain_key(m.get("group_name"))
    )

def _is_youth_tournament_category(category):
    k=_plain_key(category)
    return ("mlodziczk" in k) or ("mlodzik" in k)

def _resolve_tournament_host(selected):
    """
    For youth triangular/tournament fixtures, SZPS may list different teams as
    'home' in individual matches. The physical venue is the tournament host.

    We infer the host from the fixture in the same tournament block that has an
    explicit start time. In SZPS that timed fixture is the opening/host fixture.
    Returns dict tournament_key -> host team name.
    """
    groups={}
    for m in selected:
        groups.setdefault(_tournament_key(m),[]).append(m)

    hosts={}
    for key,items in groups.items():
        category=_category_name(items[0]) if items else ""
        if not _is_youth_tournament_category(category):
            continue

        timed=[]
        for m in items:
            tm=m.get("_time") or ""
            mins=_time_minutes(tm)
            if mins is not None:
                timed.append((mins,_to_int(m.get("match_id")),m))
        if timed:
            timed.sort(key=lambda x:(x[0],x[1]))
            host=norm(timed[0][2].get("home"))
            if host and not _is_pause_name(host):
                hosts[key]=host
    return hosts

def build_muks_schedule():
    cfg=config()
    limit=max(1,min(20,int(cfg.get("muksScheduleLimit",8))))
    payload=get_json(API+"?club="+str(MUKS_CLUB_ID))
    matches=payload.get("matches",[]) or []
    clubs=get_json(API+"?clubs").get("clubs",[]) or []
    today=time.strftime("%Y-%m-%d",time.localtime())

    selected=[]
    seen=set()
    for m in matches:
        home=norm(m.get("home"))
        away=norm(m.get("visitor"))
        if not (_is_muks_team(home) or _is_muks_team(away)):
            continue
        if not _match_is_future_or_unplayed(m,today):
            continue
        mid=str(m.get("match_id") or "")
        # Keep separate same-day tournament fixtures, dedupe only exact IDs.
        if mid and mid in seen:
            continue
        if mid:
            seen.add(mid)

        selected.append(dict(m))

    # Resolve exact match times only for the candidate future fixtures.
    for m in selected:
        m["_time"]=get_match_time(m.get("match_id"))

    def sk(m):
        t=_time_minutes(m.get("_time"))
        return (
            str(m.get("match_date") or ""),
            9999 if t is None else t,
            _to_int(m.get("match_id"))
        )

    selected.sort(key=sk)

    # Resolve the tournament host BEFORE limiting the number of displayed rows,
    # because another MUKS fixture on the same day may carry the only explicit time.
    tournament_hosts=_resolve_tournament_host(selected)
    selected=selected[:limit]

    out=[]
    for m in selected:
        home=norm(m.get("home"))
        away=norm(m.get("visitor"))
        category=_category_name(m)
        is_home=_is_muks_team(home)
        opponent=away if is_home else home
        pause=_is_pause_name(opponent)

        if pause:
            venue={"text":"","address":"","hall":""}
            venue_text="—"
            side="PAUZA"
            host_team=""
        else:
            # PRIMARY SOURCE: exact venue from this individual SZPS match page.
            # This avoids tournament-day mistakes where the team shown as "home"
            # is not the physical organiser/venue.
            direct=get_match_page_details(m.get("match_id"))
            direct_venue=norm(direct.get("venue"))

            if direct_venue:
                venue={
                    "text":direct_venue,
                    "address":direct_venue,
                    "hall":""
                }
                venue_text=direct_venue
                side="DOM" if _is_siemianowice_venue(venue) else "WYJAZD"
                host_team="SZPS match page"
            else:
                # FALLBACK only if the match page has no venue.
                host_team=tournament_hosts.get(_tournament_key(m))
                if not host_team:
                    host_team=home

                venue=_venue_for_home_team(host_team,category,clubs)

                if venue.get("text"):
                    venue_text=venue["text"]
                    side="DOM" if _is_siemianowice_venue(venue) else "WYJAZD"
                else:
                    venue_text="Miejsce do potwierdzenia"
                    side="DO USTALENIA"

        out.append({
            "id":str(m.get("match_id") or ""),
            "date":str(m.get("match_date") or ""),
            "time":m.get("_time") or "",
            "category":category,
            "group":norm(m.get("group_name")),
            "phase":norm(m.get("phase")),
            "home":home,
            "away":away,
            "opponent":opponent if not pause else "PAUZA",
            "side":side,
            "pause":pause,
            "venue":venue_text,
            "venueAddress":venue.get("address") or "",
            "venueHall":venue.get("hall") or "",
            "venueHost":host_team if not pause else "",
            "venueSource":"match_page" if (not pause and direct_venue) else ("fallback" if not pause else "")
        })

    return {
        "club":MUKS_NAME,
        "clubId":MUKS_CLUB_ID,
        "matches":out,
        "generatedAt":time.strftime("%Y-%m-%d %H:%M:%S")
    }

def _schedule_refresh_worker():
    try:
        data=build_muks_schedule()
        body=json.dumps(data,ensure_ascii=False).encode("utf-8")
        with schedule_cache_lock:
            schedule_cache["body"]=body
            schedule_cache["created"]=time.time()
            schedule_cache["error"]=None
    except Exception as e:
        with schedule_cache_lock:
            schedule_cache["error"]=str(e)
    finally:
        with schedule_cache_lock:
            schedule_cache["refreshing"]=False

def refresh_schedule_background(force=False):
    with schedule_cache_lock:
        if schedule_cache["refreshing"]:
            return False
        fresh=(
            schedule_cache["body"] is not None
            and time.time()-schedule_cache["created"] < 300
        )
        if fresh and not force:
            return False
        schedule_cache["refreshing"]=True
    threading.Thread(target=_schedule_refresh_worker,daemon=True).start()
    return True

def get_schedule_payload():
    with schedule_cache_lock:
        body=schedule_cache["body"]
        created=schedule_cache["created"]
        err=schedule_cache["error"]
        refreshing=schedule_cache["refreshing"]

    if body is not None:
        if time.time()-created >= 300 and not refreshing:
            refresh_schedule_background()
        return 200,body

    if not refreshing:
        refresh_schedule_background(force=True)

    payload={
        "loading":True,
        "message":"Pobieram terminarz MUKS Michałkowice z SZPS…"
    }
    if err:
        payload["lastError"]=err
    return 202,json.dumps(payload,ensure_ascii=False).encode("utf-8")


def _league_refresh_worker(match_id):
    try:
        data=build_league(match_id)
        body=json.dumps(data,ensure_ascii=False).encode("utf-8")
        with league_cache_lock:
            # Nie zapisuj starego meczu, jeśli użytkownik już przełączył matchId.
            if str(config().get("matchId"))==str(match_id):
                league_cache["body"]=body
                league_cache["created"]=time.time()
                league_cache["error"]=None
                league_cache["matchId"]=str(match_id)
    except Exception as e:
        with league_cache_lock:
            if str(config().get("matchId"))==str(match_id):
                league_cache["error"]=str(e)
                league_cache["matchId"]=str(match_id)
    finally:
        with league_cache_lock:
            league_cache["refreshing"]=False

def _squads_refresh_worker(match_id,lineup_set):
    try:
        data=build_squads(match_id,lineup_set)
        body=json.dumps(data,ensure_ascii=False).encode("utf-8")
        with squads_cache_lock:
            if str(config().get("matchId"))==str(match_id):
                squads_cache["body"]=body
                squads_cache["created"]=time.time()
                squads_cache["error"]=None
                squads_cache["matchId"]=str(match_id)
    except Exception as e:
        with squads_cache_lock:
            if str(config().get("matchId"))==str(match_id):
                squads_cache["error"]=str(e)
                squads_cache["matchId"]=str(match_id)
    finally:
        with squads_cache_lock:
            squads_cache["refreshing"]=False

def refresh_league_background(force=False):
    cfg=config()
    mid=str(cfg.get("matchId"))
    with league_cache_lock:
        if league_cache["refreshing"]:
            return False
        fresh=(
            league_cache["body"] is not None
            and league_cache["matchId"]==mid
            and time.time()-league_cache["created"] < 20
        )
        if fresh and not force:
            return False
        league_cache["refreshing"]=True
    threading.Thread(target=_league_refresh_worker,args=(mid,),daemon=True).start()
    return True

def refresh_squads_background(force=False):
    cfg=config()
    mid=str(cfg.get("matchId"))
    lineup_set=int(cfg.get("lineupSet",1))
    with squads_cache_lock:
        if squads_cache["refreshing"]:
            return False
        # Składy nie muszą być odświeżane tak często jak tabela.
        fresh=(
            squads_cache["body"] is not None
            and squads_cache["matchId"]==mid
            and time.time()-squads_cache["created"] < 60
        )
        if fresh and not force:
            return False
        squads_cache["refreshing"]=True
    threading.Thread(target=_squads_refresh_worker,args=(mid,lineup_set),daemon=True).start()
    return True

def get_league_payload():
    cfg=config()
    mid=str(cfg.get("matchId"))
    with league_cache_lock:
        body=league_cache["body"] if league_cache["matchId"]==mid else None
        created=league_cache["created"]
        err=league_cache["error"] if league_cache["matchId"]==mid else None
        refreshing=league_cache["refreshing"]

    if body is not None:
        if time.time()-created >= 20 and not refreshing:
            refresh_league_background()
        return 200,body

    if not refreshing:
        refresh_league_background(force=True)

    payload={
        "loading":True,
        "source":"SZPS",
        "message":"Pobieram tabelę i dane meczu z SZPS…"
    }
    if err:
        payload["lastError"]=err
    return 202,json.dumps(payload,ensure_ascii=False).encode("utf-8")

def get_squads_payload():
    cfg=config()
    mid=str(cfg.get("matchId"))
    with squads_cache_lock:
        body=squads_cache["body"] if squads_cache["matchId"]==mid else None
        created=squads_cache["created"]
        err=squads_cache["error"] if squads_cache["matchId"]==mid else None
        refreshing=squads_cache["refreshing"]

    if body is not None:
        if time.time()-created >= 60 and not refreshing:
            refresh_squads_background()
        return 200,body

    if not refreshing:
        refresh_squads_background(force=True)

    payload={
        "loading":True,
        "source":"VolleyStation",
        "message":"Sprawdzam składy w VolleyStation…"
    }
    if err:
        payload["lastError"]=err
    return 202,json.dumps(payload,ensure_ascii=False).encode("utf-8")


def _send_json(handler, status, obj):
    body=json.dumps(obj,ensure_ascii=False).encode("utf-8")
    handler.send_response(status)
    handler.send_header("Content-Type","application/json; charset=utf-8")
    handler.send_header("Cache-Control","no-store")
    handler.send_header("Content-Length",str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)

def _read_json_body(handler):
    try:
        ln=int(handler.headers.get("Content-Length","0"))
    except Exception:
        ln=0
    raw=handler.rfile.read(ln) if ln>0 else b"{}"
    try:
        return json.loads(raw.decode("utf-8"))
    except Exception:
        return {}

def _admin_ok(password):
    try:
        cfg=config()
        expected=str(cfg.get("adminPassword") or "")
        return bool(expected) and secrets.compare_digest(str(password or ""),expected)
    except Exception:
        return False

def _clear_runtime_caches():
    with lock:
        resolved.clear()
        vs_cache.clear()

    with league_cache_lock:
        league_cache["body"]=None
        league_cache["created"]=0
        league_cache["error"]=None
        league_cache["matchId"]=None

    with squads_cache_lock:
        squads_cache["body"]=None
        squads_cache["created"]=0
        squads_cache["error"]=None
        squads_cache["matchId"]=None

def _write_config_atomic(new_cfg):
    tmp=CONFIG+".tmp"
    with open(tmp,"w",encoding="utf-8") as f:
        json.dump(new_cfg,f,ensure_ascii=False,indent=2)
    os.replace(tmp,CONFIG)


def get_overlay_control():
    try:
        with open(OVERLAY_CONTROL,encoding="utf-8") as f:
            data=json.load(f)
        mode=str(data.get("mode") or "hidden")
    except Exception:
        mode="hidden"
    if mode not in ("hidden","league","squads","schedule"):
        mode="hidden"
    return {"mode":mode}

def save_overlay_control(mode):
    mode=str(mode or "hidden")
    if mode not in ("hidden","league","squads","schedule"):
        raise ValueError("Nieprawidłowy tryb overlaya.")
    tmp=OVERLAY_CONTROL+".tmp"
    with open(tmp,"w",encoding="utf-8") as f:
        json.dump({"mode":mode},f,ensure_ascii=False,indent=2)
    os.replace(tmp,OVERLAY_CONTROL)

class H(SimpleHTTPRequestHandler):
    def do_GET(self):
        path=self.path.split("?")[0]

        if path=="/overlay_control.json":
            _send_json(self,200,get_overlay_control())
            return

        # /data.json pozostaje jako zgodny wstecz alias tabeli.
        if path in ("/league.json","/data.json"):
            status,body=get_league_payload()
            try:
                self.send_response(status)
                self.send_header("Content-Type","application/json; charset=utf-8")
                self.send_header("Cache-Control","no-store, no-cache, must-revalidate")
                self.send_header("Pragma","no-cache")
                self.send_header("Content-Length",str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
                pass
            return

        if path=="/squads.json":
            status,body=get_squads_payload()
            try:
                self.send_response(status)
                self.send_header("Content-Type","application/json; charset=utf-8")
                self.send_header("Cache-Control","no-store, no-cache, must-revalidate")
                self.send_header("Pragma","no-cache")
                self.send_header("Content-Length",str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
                pass
            return

        if path=="/muks_schedule.json":
            status,body=get_schedule_payload()
            try:
                self.send_response(status)
                self.send_header("Content-Type","application/json; charset=utf-8")
                self.send_header("Cache-Control","no-store, no-cache, must-revalidate")
                self.send_header("Pragma","no-cache")
                self.send_header("Content-Length",str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
                pass
            return

        try:
            super().do_GET()
        except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
            pass

    def do_POST(self):
        path=self.path.split("?")[0]

        if path=="/api/admin/overlay":
            data=_read_json_body(self)
            if not _admin_ok(data.get("password")):
                _send_json(self,403,{"ok":False,"error":"Nieprawidłowe hasło administratora."})
                return
            try:
                save_overlay_control(data.get("mode"))
                _send_json(self,200,{"ok":True,"mode":get_overlay_control()["mode"]})
            except Exception as e:
                _send_json(self,400,{"ok":False,"error":str(e)})
            return

        if path=="/api/admin/get":
            data=_read_json_body(self)
            if not _admin_ok(data.get("password")):
                _send_json(self,403,{"ok":False,"error":"Nieprawidłowe hasło administratora."})
                return
            cfg=config()
            _send_json(self,200,{
                "ok":True,
                "matchId":cfg.get("matchId"),
                "lineupSet":cfg.get("lineupSet",1),
                "refreshSeconds":cfg.get("refreshSeconds",15),
                "previousMatchesLimit":cfg.get("previousMatchesLimit",4),
                "shirtColors": get_shirt_colors()
            })
            return

        if path=="/api/admin/set":
            data=_read_json_body(self)
            if not _admin_ok(data.get("password")):
                _send_json(self,403,{"ok":False,"error":"Nieprawidłowe hasło administratora."})
                return
            try:
                match_id=int(data.get("matchId"))
                lineup_set=int(data.get("lineupSet",1))
                if match_id<=0:
                    raise ValueError("matchId")
                if lineup_set<1 or lineup_set>5:
                    raise ValueError("lineupSet")
            except Exception:
                _send_json(self,400,{"ok":False,"error":"Podaj poprawny numer meczu i set 1-5."})
                return

            colors=data.get("shirtColors") or {}
            defaults=_default_shirt_colors()
            shirt_colors={}
            for k,v in defaults.items():
                x=str(colors.get(k) or v).strip()
                if not re.fullmatch(r"#[0-9A-Fa-f]{6}", x):
                    _send_json(self,400,{"ok":False,"error":"Każdy kolor koszulki musi mieć format #RRGGBB."})
                    return
                shirt_colors[k]=x

            # Validate the SZPS match before overwriting config.json.
            try:
                checked=resolve(match_id)
                checked_match=checked.get("match") or {}
            except Exception as e:
                _send_json(self,400,{
                    "ok":False,
                    "error":"Nie zmieniono meczu. "+str(e)
                })
                return

            vs_available=True
            vs_note=""
            try:
                vs_id,_=get_volleystation_info(match_id)
            except Exception as e:
                vs_available=False
                vs_id=None
                vs_note=str(e)

            cfg=config()
            cfg["matchId"]=match_id
            cfg["lineupSet"]=lineup_set
            cfg["shirtColors"]=shirt_colors
            _write_config_atomic(cfg)
            _clear_runtime_caches()

            # Tabela i składy są od tej wersji całkowicie niezależne.
            refresh_league_background(force=True)
            refresh_squads_background(force=True)

            _send_json(self,200,{
                "ok":True,
                "message":"Mecz SZPS i ustawienia koszulek zapisane.",
                "matchId":match_id,
                "lineupSet":lineup_set,
                "home":checked_match.get("home"),
                "away":checked_match.get("visitor"),
                "shirtColors":shirt_colors,
                "volleyStationAvailable":vs_available,
                "volleyStationId":vs_id,
                "volleyStationNote":vs_note
            })
            return

        _send_json(self,404,{"ok":False,"error":"Nie znaleziono endpointu."})


def get_lan_ip():
    try:
        probe=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
        probe.connect(("8.8.8.8",80))
        ip=probe.getsockname()[0]
        probe.close()
        return ip
    except Exception:
        return "ADRES_IP_KOMPUTERA"


if __name__=="__main__":
    os.chdir(BASE)
    lan_ip=get_lan_ip()
    print("SZPS + VolleyStation overlay server")
    print("")
    print("Na tym komputerze:")
    print("Liga:   http://127.0.0.1:8787/overlay_liga.html")
    print("Sklady: http://127.0.0.1:8787/overlay_sklady.html")
    print("")
    print("Na telefonie w tej samej sieci Wi-Fi:")
    print("Liga:   http://"+lan_ip+":8787/overlay_liga.html")
    print("Sklady: http://"+lan_ip+":8787/overlay_sklady.html")
    print("")
    print("UWAGA: jesli Windows Firewall zapyta o dostep, zaznacz Sieci prywatne.")
    print("Przygotowuję tabelę i składy niezależnie w tle...")
    refresh_league_background(force=True)
    refresh_squads_background(force=True)
    refresh_schedule_background(force=True)
    ThreadingHTTPServer(("0.0.0.0",8787),H).serve_forever()
