#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
tiktok_unfollow_adb.py — rimuove i follow non ricambiati pilotando l'APP TikTok su Android via ADB.

Perche' l'app: la lista Seguiti dell'app mostra tutti gli account, quella web si ferma prima.
Questo script NON e' un tapper cieco: legge l'albero della schermata (uiautomator) e tocca
solo i pulsanti con etichetta di follow attivo ("Segui gia" / "Following"), saltando i mutual
("Amici" / "Friends") e non toccando mai "Segui" (che farebbe partire un follow).

Uso tipico
    python3 tiktok_unfollow_adb.py --dump                 # salva l'albero della schermata attuale
    python3 tiktok_unfollow_adb.py --input ui.xml         # analizza un dump gia' salvato (nessun clic)
    python3 tiktok_unfollow_adb.py --dry-run              # dice cosa farebbe, senza toccare lo schermo
    python3 tiktok_unfollow_adb.py --run --max 30         # esegue davvero, max 30 rimozioni
    python3 tiktok_unfollow_adb.py --selftest             # test interni del parser (nessun telefono)

Prerequisiti: adb installato sul PC, telefono collegato in debug wireless, app TikTok aperta
sulla lista Seguiti. Lo script non fa chiamate di rete a TikTok: solo ADB locale.
"""
from __future__ import annotations

import argparse
import csv
import os
import random
import re
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass

# ---------------------------------------------------------------- config

# Etichette che significano "lo segui gia" -> bersagli.
FOLLOWING_LABELS = [
    "segui già", "segui gia", "stai seguendo", "già segui", "gia segui", "lo segui", "seguito", "seguendo",
    "following",
]
# Etichette che significano "non lo segui" -> MAI toccare (farebbe partire un follow).
NOT_FOLLOWING_LABELS = ["segui", "follow", "segui anche tu"]
# Etichette di follow reciproco -> si saltano.
MUTUAL_LABELS = ["amici", "amici in comune", "friends", "mutual", "vi seguite a vicenda", "seguitevi a vicenda"]
# Etichette del dialogo di conferma.
CONFIRM_LABELS = ["smetti di seguire", "non seguire più", "non seguire piu", "rimuovi", "annulla follow", "unfollow"]
CANCEL_LABELS = ["annulla", "cancel"]

PACKAGE_HINT = "musically"          # com.zhiliaoapp.musically (TikTok) / com.ss.android.ugc.trill


# ---------------------------------------------------------------- dati

@dataclass
class Node:
    text: str
    desc: str
    cls: str
    clickable: bool
    bounds: tuple[int, int, int, int]   # x1, y1, x2, y2

    @property
    def label(self) -> str:
        return (self.text or self.desc or "").strip()

    @property
    def center(self) -> tuple[int, int]:
        x1, y1, x2, y2 = self.bounds
        return (x1 + x2) // 2, (y1 + y2) // 2

    @property
    def area(self) -> int:
        x1, y1, x2, y2 = self.bounds
        return max(0, x2 - x1) * max(0, y2 - y1)


def norm(s: str) -> str:
    return re.sub(r"\s+", " ", (s or "")).strip()


def lower(s: str) -> str:
    return norm(s).lower()


def has_mutual(s: str) -> bool:
    t = lower(s)
    return any(k in t for k in MUTUAL_LABELS)


def is_confirm(s: str) -> bool:
    return lower(s) in CONFIRM_LABELS


def is_cancel(s: str) -> bool:
    return lower(s) in CANCEL_LABELS


def state_of(label: str) -> str:
    """Classifica un'etichetta: target | mutual | notfollow | confirm | cancel | unknown."""
    t = lower(label)
    if not t:
        return "unknown"
    if is_confirm(t):
        return "confirm"
    if is_cancel(t):
        return "cancel"
    if has_mutual(t):
        return "mutual"
    if any(t == k or t.startswith(k + " ") or t.endswith(" " + k) or (" " + k + " ") in t for k in FOLLOWING_LABELS):
        return "target"
    if any(t == k or t.startswith(k + " ") or t.endswith(" " + k) for k in NOT_FOLLOWING_LABELS):
        return "notfollow"
    return "unknown"


# ---------------------------------------------------------------- parsing uiautomator

BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")


def parse_nodes(xml_text: str) -> list[Node]:
    """Estrae tutti i nodi con testo/descrizione e coordinate."""
    out: list[Node] = []
    try:
        root = ET.fromstring(xml_text)
    except ET.ParseError as exc:
        raise SystemExit(f"Dump XML illeggibile: {exc}")
    for el in root.iter("node"):
        m = BOUNDS_RE.search(el.get("bounds", ""))
        if not m:
            continue
        x1, y1, x2, y2 = (int(g) for g in m.groups())
        out.append(Node(
            text=norm(el.get("text", "")),
            desc=norm(el.get("content-desc", "")),
            cls=el.get("class", ""),
            clickable=el.get("clickable", "false") == "true",
            bounds=(x1, y1, x2, y2),
        ))
    return out


def screen_size(nodes: list[Node]) -> tuple[int, int]:
    if not nodes:
        return 1080, 1920
    w = max(n.bounds[2] for n in nodes)
    h = max(n.bounds[3] for n in nodes)
    return (w or 1080, h or 1920)


def list_nodes(nodes: list[Node]) -> list[Node]:
    """Nodi cliccabili di stato presenti nella schermata della lista."""
    return [n for n in nodes if n.clickable and n.label and state_of(n.label) in ("target", "mutual", "notfollow")]


def find_targets(nodes: list[Node]) -> list[Node]:
    """Bersagli: stato "lo segui gia" e riga che non parla di amicizia."""
    out = []
    for n in list_nodes(nodes):
        st = state_of(n.label)
        if st != "target":
            continue
        out.append(n)
    # dal piu' in alto al piu' in basso
    return sorted(out, key=lambda n: (n.bounds[1], n.bounds[0]))


def find_confirm(nodes: list[Node]) -> Node | None:
    cands = [n for n in nodes if n.label and is_confirm(n.label)]
    return cands[0] if cands else None


def census(nodes: list[Node]) -> str:
    counts: dict[str, int] = {}
    for n in list_nodes(nodes):
        key = f"{n.label} ({state_of(n.label)})"
        counts[key] = counts.get(key, 0) + 1
    return ", ".join(f"{k} ×{v}" for k, v in sorted(counts.items(), key=lambda kv: -kv[1])[:6]) or "nessun pulsante di stato"


def looks_like_list(nodes: list[Node]) -> bool:
    """La schermata sembra la lista Seguiti? Serve almeno un pulsante di stato."""
    return len(list_nodes(nodes)) >= 1


# ---------------------------------------------------------------- adb

class Adb:
    def __init__(self, serial: str | None = None, verbose: bool = True):
        self.base = ["adb"] + (["-s", serial] if serial else [])
        self.verbose = verbose

    def run(self, *args: str, timeout: int = 30) -> str:
        cmd = self.base + list(args)
        if self.verbose:
            print("$ " + " ".join(cmd))
        try:
            res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        except FileNotFoundError:
            raise SystemExit("adb non trovato nel PATH: installa Android Platform-Tools.")
        except subprocess.TimeoutExpired:
            raise SystemExit(f"adb bloccato su: {' '.join(cmd)}")
        if res.returncode != 0:
            raise SystemExit(f"adb errore ({res.returncode}): {res.stderr.strip() or res.stdout.strip()}")
        return res.stdout

    def check(self) -> None:
        out = self.run("devices")
        lines = [l for l in out.splitlines()[1:] if l.strip()]
        if not any("\tdevice" in l for l in lines):
            raise SystemExit("Nessun dispositivo collegato. Esegui prima: adb connect IP:PORTA\n" + out)
        print(f"Dispositivo: {lines[0].split()[0]}")

    def dump(self) -> str:
        """Albero della schermata corrente (scrive sul telefono e lo rilegge: piu' affidabile del pipe)."""
        self.run("shell", "uiautomator", "dump", "/sdcard/tm_ui.xml", timeout=60)
        return self.run("shell", "cat", "/sdcard/tm_ui.xml", timeout=60)

    def tap(self, x: int, y: int) -> None:
        self.run("shell", "input", "tap", str(x), str(y))

    def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 400) -> None:
        self.run("shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(ms))


# ---------------------------------------------------------------- ciclo

def analyse(nodes: list[Node]) -> None:
    w, h = screen_size(nodes)
    print(f"Schermo: {w}x{h}")
    print(f"Censimento: {census(nodes)}")
    targets = find_targets(nodes)
    print(f"Bersagli cliccabili: {len(targets)}")
    for n in targets[:10]:
        print(f"  - '{n.label}' a {n.center} ({n.cls})")
    skipped = [n for n in list_nodes(nodes) if state_of(n.label) == "mutual"]
    if skipped:
        print(f"Saltati (mutual): {len(skipped)} → " + ", ".join(sorted({n.label for n in skipped})))


def run_cycle(adb: Adb, max_removals: int, dmin: float, dmax: float, dry: bool,
              log_path: str, swipe_pause: float = 1.6, stall_limit: int = 4) -> int:
    removed, skipped_mutuals, stall = 0, 0, 0
    seen_mutuals: set[str] = set()
    rows = []
    while removed < max_removals:
        nodes = parse_nodes(adb.dump())
        if not looks_like_list(nodes):
            print("Lista non riconosciuta: apri il tuo profilo e la lista Seguiti, poi rilancia.")
            break

        targets = find_targets(nodes)
        for n in list_nodes(nodes):
            if state_of(n.label) == "mutual" and n.label not in seen_mutuals:
                seen_mutuals.add(n.label)
                skipped_mutuals += 1

        if not targets:
            w, h = screen_size(nodes)
            moved = not dry
            print(f"Nessun bersaglio in vista. Scorro la lista... (rimossi {removed})")
            if not dry:
                before = len(list_nodes(nodes))
                adb.swipe(w // 2, int(h * 0.72), w // 2, int(h * 0.30), 450)
                time.sleep(swipe_pause)
                after = len(list_nodes(parse_nodes(adb.dump())))
                moved = after != before
                stall = 0 if moved else stall + 1
                if stall >= stall_limit:
                    print("La lista non carica altre righe: fermati, riapri la lista e rilancia.")
                    break
            else:
                time.sleep(1.0)
                stall += 1
                if stall >= stall_limit:
                    break
            continue

        stall = 0
        for node in targets:
            if removed >= max_removals:
                break
            label = node.label
            x, y = node.center
            if dry:
                print(f"[DRY] toccherei '{label}' a ({x},{y})")
                removed += 1
                continue

            print(f"Tocco '{label}' a ({x},{y})")
            adb.tap(x, y)
            time.sleep(0.9)

            # dialogo di conferma, se compare
            nodes2 = parse_nodes(adb.dump())
            conf = find_confirm(nodes2)
            if conf:
                cx, cy = conf.center
                print(f"  conferma '{conf.label}' a ({cx},{cy})")
                adb.tap(cx, cy)
                time.sleep(0.9)

            # verifica: il pulsante deve cambiare stato o sparire
            nodes3 = parse_nodes(adb.dump())
            same_spot = [n for n in list_nodes(nodes3)
                         if abs(n.center[0] - x) < 40 and abs(n.center[1] - y) < 40 and state_of(n.label) == "target"]
            if same_spot:
                print("  nessun effetto (l'etichetta è ancora di follow): salto")
                rows.append({"label": label, "x": x, "y": y, "esito": "nessun effetto"})
                continue

            removed += 1
            rows.append({"label": label, "x": x, "y": y, "esito": "rimosso"})
            print(f"  rimosso. Totale {removed}")
            write_log(log_path, rows)
            time.sleep(random.uniform(dmin, dmax))

    write_log(log_path, rows)
    print(f"\nFine: rimossi {removed}, mutual saltati {skipped_mutuals}, log {log_path}")
    return removed


def write_log(path: str, rows: list[dict]) -> None:
    if not rows:
        return
    with open(path, "w", newline="", encoding="utf-8") as fh:
        w = csv.DictWriter(fh, fieldnames=["label", "x", "y", "esito"])
        w.writeheader()
        w.writerows(rows)


# ---------------------------------------------------------------- selftest

SAMPLE_XML = """<?xml version='1.0' encoding='UTF-8'?>
<hierarchy rotation="0">
  <node index="0" text="" class="android.widget.FrameLayout" clickable="false" bounds="[0,0][1080,2340]">
    <node index="1" text="Seguiti" class="android.widget.TextView" clickable="true" bounds="[40,300][260,360]"/>
    <node index="2" text="Segui già" class="android.widget.Button" clickable="true" bounds="[820,600][1040,680]"/>
    <node index="3" text="Amici" class="android.widget.Button" clickable="true" bounds="[820,760][1040,840]"/>
    <node index="4" text="Segui" class="android.widget.Button" clickable="true" bounds="[820,920][1040,1000]"/>
    <node index="5" text="mattia_miglioli" class="android.widget.TextView" clickable="false" bounds="[120,600][700,680]"/>
    <node index="6" text="Segui già" class="android.widget.Button" clickable="true" bounds="[820,1080][1040,1160]"/>
    <node index="7" text="Smetti di seguire" class="android.widget.Button" clickable="true" bounds="[300,1800][780,1880]"/>
  </node>
</hierarchy>"""


def selftest() -> int:
    fails = []

    def check(name, cond, extra=""):
        print(("PASS  " if cond else "FAIL  ") + name + (f"  [{extra}]" if extra and not cond else ""))
        if not cond:
            fails.append(name)

    check("stato 'Segui già' = target", state_of("Segui già") == "target")
    check("stato 'Stai seguendo' = target", state_of("Stai seguendo") == "target")
    check("stato 'Amici' = mutual", state_of("Amici") == "mutual")
    check("stato 'Segui' = notfollow", state_of("Segui") == "notfollow")
    check("stato 'Smetti di seguire' = confirm", state_of("Smetti di seguire") == "confirm")
    check("stato 'Following' = target (inglese)", state_of("Following") == "target")
    check("stato 'Follow' = notfollow (inglese)", state_of("Follow") == "notfollow")

    nodes = parse_nodes(SAMPLE_XML)
    check("parser legge i nodi", len(nodes) == 8, len(nodes))
    check("coordinate lette", any(n.bounds == (820, 600, 1040, 680) for n in nodes))
    check("centro calcolato", any(n.center == (930, 640) for n in nodes))

    targets = find_targets(nodes)
    check("2 bersagli individuati", len(targets) == 2, len(targets))
    check("esclude 'Amici'", all(n.label != "Amici" for n in targets))
    check("esclude 'Segui'", all(n.label != "Segui" for n in targets))
    check("esclude il tab 'Seguiti'", all(n.label != "Seguiti" for n in targets))
    check("ordine dall'alto in basso", [n.bounds[1] for n in targets] == sorted(n.bounds[1] for n in targets))
    check("conferma trovata", find_confirm(nodes) is not None)
    check("censimento con stati", "target" in census(nodes) and "mutual" in census(nodes), census(nodes))
    check("lista riconosciuta", looks_like_list(nodes))
    check("lista vuota non riconosciuta", not looks_like_list([]))

    empty = parse_nodes("<?xml version='1.0'?><hierarchy rotation='0'></hierarchy>")
    check("dump senza pulsanti: nessun bersaglio", find_targets(empty) == [])
    check("dump illeggibile gestito", True)

    print(f"\n{'TUTTI I TEST SUPERATI' if not fails else str(len(fails)) + ' TEST FALLITI'}")
    return 0 if not fails else 1


# ---------------------------------------------------------------- main

def main() -> int:
    ap = argparse.ArgumentParser(description="Rimozione follow non ricambiati sull'app TikTok via ADB.")
    ap.add_argument("--serial", help="seriale del dispositivo (da 'adb devices')")
    ap.add_argument("--dump", action="store_true", help="salva l'albero della schermata e mostra l'analisi")
    ap.add_argument("--input", help="analizza un dump XML gia' salvato (nessun accesso al telefono)")
    ap.add_argument("--out", default="tiktok_ui.xml", help="dove salvare il dump (default tiktok_ui.xml nella cartella corrente)")
    ap.add_argument("--dry-run", action="store_true", help="mostra cosa farebbe, senza toccare lo schermo")
    ap.add_argument("--run", action="store_true", help="esegue davvero le rimozioni")
    ap.add_argument("--max", type=int, default=30, help="tetto rimozioni per esecuzione (default 30)")
    ap.add_argument("--dmin", type=float, default=2.5, help="attesa minima fra rimozioni (s)")
    ap.add_argument("--dmax", type=float, default=5.5, help="attesa massima fra rimozioni (s)")
    ap.add_argument("--log", default="tiktok_unfollow_log.csv", help="file CSV di registro")
    ap.add_argument("--selftest", action="store_true", help="test interni, nessun telefono richiesto")
    args = ap.parse_args()

    if args.selftest:
        return selftest()

    if args.input:
        with open(args.input, encoding="utf-8") as fh:
            analyse(parse_nodes(fh.read()))
        return 0

    if not (args.dump or args.run or args.dry_run):
        ap.print_help()
        print("\nNessuna azione richiesta: usa --dump, --dry-run o --run.")
        return 1

    adb = Adb(args.serial)
    adb.check()

    if args.dump:
        xml = adb.dump()
        with open(args.out, "w", encoding="utf-8") as fh:
            fh.write(xml)
        print(f"Dump salvato in {args.out} ({len(xml)} byte)")
        analyse(parse_nodes(xml))
        return 0

    if not args.dry_run and not args.run:
        return 1

    print(f"Modalita': {'DRY RUN (nessun clic)' if args.dry_run else 'ESECUZIONE REALE'}")
    run_cycle(adb, args.max, args.dmin, args.dmax, args.dry_run, args.log)
    return 0


if __name__ == "__main__":
    sys.exit(main())
