#!/usr/bin/env python3
"""
Max Intel Linkgraph — every referring domain for a site, from the Common Crawl webgraph.

Part of the Max Intel OSINT toolkit:  https://maxintel.org/find-backlinks-guide-2026.html

This is the closest thing to a working `link:` operator that still exists in public.
Google deprecated `link:` around 2017, Yandex killed theirs in 2007, and Bing pulled
`linkdomain:` after it was mass-mined. Common Crawl publishes the raw hyperlink graph
of its crawls instead; this tool streams the domain-level graph and pulls out every
domain with an edge pointing at yours.

    pip install customtkinter requests
    python maxintel-linkgraph.py

How it works (domain-level graph, e.g. cc-main-2026-apr-may-jun):

    vertices.txt.gz   ~0.9 GB   id 	 reversed_domain 	 n_hosts   (sorted, id == line no.)
    edges.txt.gz     ~14.6 GB   from_id 	 to_id
    ranks.txt.gz      ~2.4 GB   harmonic_pos 	 harmonic_val 	 pr_pos 	 pr_val 	 domain_rev 	 n_hosts

    1. scan vertices  -> resolve your domain to a vertex id
    2. scan edges     -> collect every from_id whose to_id is yours
    3. scan vertices  -> turn those ids back into domain names
    4. scan ranks     -> (optional) attach authority scores so you can sort

Nothing is queried server-side: the filtering happens on your machine, so a full
run transfers ~17 GB. Measured throughput on a fast link is ~34 MB/s, putting a
complete lookup at roughly 12 minutes; on a 100 Mbps home line expect ~30. The
work is network-bound, not CPU-bound. Enable caching if you plan to look up more
than one domain against the same graph.

Caveats worth knowing:
  * Common Crawl is a *sample* of the web, not a complete index. Absence of a
    link here is not evidence the link doesn't exist.
  * The graph is domain-level: you get referring domains, not referring URLs.
  * Every kind of link counts, including technical ones (images, JS, fonts, CDNs).
    A CDN or analytics domain in your results is a <script> tag, not an endorsement.
  * There is no anchor text, no dofollow/nofollow flag, and no first-seen date.
    If you need those, that is what the commercial indexes are for.
  * "www." is stripped and names are stored in reverse notation (com.example).

Data: Common Crawl webgraph, CC-BY / public data — https://commoncrawl.org/web-graphs
Licence: MIT. Provided as-is, no warranty.
"""

from __future__ import annotations

import csv
import os
import queue
import re
import subprocess
import sys
import threading
import time
import tkinter as tk
import zlib
from dataclasses import dataclass
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

try:
    import customtkinter as ctk
    import requests
except ImportError as exc:  # pragma: no cover
    sys.exit(f"Missing dependency ({exc.name}). Run: pip install customtkinter requests")


# --------------------------------------------------------------------------
# Constants
# --------------------------------------------------------------------------

GRAPHINFO_URL = "https://index.commoncrawl.org/graphinfo.json"
DATA_BASE = "https://data.commoncrawl.org/projects/hyperlinkgraph"
USER_AGENT = "maxintel-linkgraph/1.0 (+https://maxintel.org/find-backlinks-guide-2026.html)"

CHUNK = 1 << 23          # 8 MiB network / disk reads
MAX_DISPLAY_ROWS = 5000  # Treeview chokes well before the CSV does
CACHE_DIR = Path.home() / ".cache" / "maxintel-linkgraph"

FALLBACK_RELEASES = ["cc-main-2026-apr-may-jun", "cc-main-2026-mar-apr-may"]


def file_url(release: str, kind: str) -> str:
    """kind is one of vertices / edges / ranks."""
    return f"{DATA_BASE}/{release}/domain/{release}-domain-{kind}.txt.gz"


def human_bytes(n: float) -> str:
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024 or unit == "TB":
            return f"{n:,.1f} {unit}" if unit != "B" else f"{int(n)} B"
        n /= 1024
    return f"{n:.1f} TB"


# --------------------------------------------------------------------------
# Domain normalisation
#
# The domain graph stores pay-level domains in reverse notation. Rather than
# ship a public suffix list, we generate every reversed prefix of the input and
# let the graph itself tell us which one is a real node. "blog.example.co.uk"
# yields uk / uk.co / uk.co.example / uk.co.example.blog — only "uk.co.example"
# will exist as a vertex, and if several match we take the longest.
# --------------------------------------------------------------------------

def normalise_domain(raw: str) -> str:
    s = raw.strip().lower()
    s = re.sub(r"^[a-z][a-z0-9+.-]*://", "", s)
    s = s.split("/")[0].split("?")[0].split("#")[0]
    s = s.split("@")[-1]
    s = s.split(":")[0]
    if s.startswith("www."):
        s = s[4:]
    return s.strip(".")


def reverse_domain(d: str) -> str:
    return ".".join(reversed(d.split(".")))


def pld_candidates(domain: str) -> list[str]:
    labels = list(reversed(domain.split(".")))
    return [".".join(labels[:i]) for i in range(1, len(labels) + 1)]


# --------------------------------------------------------------------------
# Streaming primitives
# --------------------------------------------------------------------------

class Cancelled(Exception):
    pass


def remote_size(url: str) -> int:
    r = requests.head(url, headers={"User-Agent": USER_AGENT},
                      timeout=30, allow_redirects=True)
    r.raise_for_status()
    return int(r.headers.get("content-length", 0))


def raw_chunks(url, cache_path, cancel, progress):
    """Yield compressed bytes, from the local cache when we have a complete copy."""
    total = remote_size(url)

    if cache_path and cache_path.exists() and cache_path.stat().st_size == total:
        read = 0
        with cache_path.open("rb") as fh:
            while True:
                if cancel.is_set():
                    raise Cancelled
                blob = fh.read(CHUNK)
                if not blob:
                    break
                read += len(blob)
                progress(read, total, cached=True)
                yield blob
        return

    part = Path(str(cache_path) + ".part") if cache_path else None
    out = None
    if part:
        part.parent.mkdir(parents=True, exist_ok=True)
        out = part.open("wb")
    try:
        with requests.get(url, headers={"User-Agent": USER_AGENT},
                          stream=True, timeout=(30, 120)) as r:
            r.raise_for_status()
            read = 0
            for blob in r.iter_content(CHUNK):
                if cancel.is_set():
                    raise Cancelled
                if not blob:
                    continue
                read += len(blob)
                progress(read, total, cached=False)
                if out:
                    out.write(blob)
                yield blob
        if out:
            out.close()
            out = None
            part.replace(cache_path)
    finally:
        if out:
            out.close()
            part.unlink(missing_ok=True)


def gunzip(chunks):
    """Decompress a stream of gzip bytes, tolerating concatenated members."""
    dec = zlib.decompressobj(31)
    for blob in chunks:
        while blob:
            data = dec.decompress(blob)
            if data:
                yield data
            if dec.eof:
                blob = dec.unused_data
                dec = zlib.decompressobj(31)
            else:
                blob = b""
    tail = dec.flush()
    if tail:
        yield tail


def iter_lines(chunks):
    carry = b""
    for blob in chunks:
        buf = carry + blob if carry else blob
        parts = buf.split(b"\n")
        carry = parts.pop()
        yield from parts
    if carry:
        yield carry


def _harvest(region: bytes, patterns: list[bytes]):
    """Yield whole lines from a newline-aligned region containing any pattern."""
    for pat in patterns:
        pos = region.find(pat)
        while pos != -1:
            start = region.rfind(b"\n", 0, pos) + 1
            end = region.find(b"\n", pos)
            if end == -1:
                end = len(region)
            yield region[start:end]
            pos = region.find(pat, pos + 1)


def scan_for_patterns(chunks, patterns: list[bytes]):
    """
    Find matching lines without paying Python's per-line cost.

    The edges file has ~4 billion lines. Iterating them individually takes tens
    of minutes; letting bytes.find() sweep 8 MiB blocks in C takes a couple of
    minutes and only materialises the handful of lines that actually match.
    """
    carry = b""
    for blob in chunks:
        buf = carry + blob if carry else blob
        nl = buf.rfind(b"\n")
        if nl < 0:
            carry = buf
            continue
        region, carry = buf[: nl + 1], buf[nl + 1 :]
        yield from _harvest(region, patterns)
    if carry:
        yield from _harvest(carry + b"\n", patterns)


# --------------------------------------------------------------------------
# The job
# --------------------------------------------------------------------------

@dataclass
class Row:
    domain: str
    n_hosts: int = 0
    harmonic_pos: int | None = None
    pagerank_pos: int | None = None


@dataclass
class JobSpec:
    domain: str
    release: str
    with_ranks: bool
    cache: bool


class Emitter:
    """Marshals worker-thread events onto the Tk main loop via a queue."""

    def __init__(self, q: queue.Queue):
        self.q = q
        self._last = 0.0

    def log(self, msg):
        self.q.put(("log", msg))

    def phase(self, text):
        self.q.put(("phase", text))

    def progress(self, done, total, cached=False, force=False):
        now = time.monotonic()
        if not force and now - self._last < 0.15:
            return
        self._last = now
        self.q.put(("progress", (done, total, cached)))

    def done(self, rows):
        self.q.put(("done", rows))

    def fail(self, msg):
        self.q.put(("fail", msg))


def cache_path_for(spec: JobSpec, kind: str) -> Path | None:
    if not spec.cache:
        return None
    return CACHE_DIR / spec.release / f"{spec.release}-domain-{kind}.txt.gz"


def source(spec: JobSpec, kind: str, cancel, emit: Emitter):
    url = file_url(spec.release, kind)
    return gunzip(raw_chunks(url, cache_path_for(spec, kind), cancel,
                             lambda d, t, cached: emit.progress(d, t, cached)))


def run_job(spec: JobSpec, cancel: threading.Event, emit: Emitter):
    steps = 4 if spec.with_ranks else 3

    # ---- 1. locate the domain ------------------------------------------
    emit.phase(f"Step 1/{steps} — locating {spec.domain} in the graph")
    emit.log(f"Release {spec.release}, target {spec.domain}")

    candidates = pld_candidates(spec.domain)
    patterns = [b"\t" + c.encode() + b"\t" for c in candidates]
    hits: dict[str, bytes] = {}
    for line in scan_for_patterns(source(spec, "vertices", cancel, emit), patterns):
        parts = line.split(b"\t")
        if len(parts) >= 2:
            hits[parts[1].decode("utf-8", "replace")] = parts[0]

    match = max((c for c in candidates if c in hits), key=len, default=None)
    if match is None:
        raise LookupError(
            f"{spec.domain} is not a node in this graph.\n\n"
            "Either Common Crawl didn't reach it in these crawls, or it has no "
            "inbound links from other domains. Try an older release, or check "
            "the spelling."
        )

    target_id = hits[match]
    resolved = ".".join(reversed(match.split(".")))
    emit.log(f"Resolved to '{resolved}' — vertex id {target_id.decode()}")
    if resolved != spec.domain:
        emit.log(f"(the graph is domain-level, so {spec.domain} folds into {resolved})")

    # ---- 2. sweep the edges --------------------------------------------
    emit.phase(f"Step 2/{steps} — sweeping the edge list (this is the slow one)")
    from_ids: set[bytes] = set()
    suffix = [b"\t" + target_id + b"\n"]
    for line in scan_for_patterns(source(spec, "edges", cancel, emit), suffix):
        src = line.split(b"\t", 1)[0]
        if src != target_id:            # drop self-links
            from_ids.add(src)

    emit.log(f"Found {len(from_ids):,} referring domains")
    if not from_ids:
        return []

    # ---- 3. ids back to names ------------------------------------------
    emit.phase(f"Step 3/{steps} — resolving {len(from_ids):,} ids to domain names")
    rows: dict[str, Row] = {}
    for line in iter_lines(source(spec, "vertices", cancel, emit)):
        if cancel.is_set():
            raise Cancelled
        tab = line.find(b"\t")
        if tab < 0 or line[:tab] not in from_ids:
            continue
        parts = line.split(b"\t")
        name_rev = parts[1].decode("utf-8", "replace")
        n_hosts = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0
        rows[name_rev] = Row(domain=".".join(reversed(name_rev.split("."))),
                             n_hosts=n_hosts)
        if len(rows) == len(from_ids):
            break

    # ---- 4. authority ---------------------------------------------------
    if spec.with_ranks and rows:
        emit.phase(f"Step 4/{steps} — attaching authority ranks")
        remaining = len(rows)
        for line in iter_lines(source(spec, "ranks", cancel, emit)):
            if cancel.is_set():
                raise Cancelled
            if line.startswith(b"#"):
                continue
            parts = line.split(b"\t")
            if len(parts) < 5:
                continue
            key = parts[4].decode("utf-8", "replace")
            row = rows.get(key)
            if row is None:
                continue
            try:
                row.harmonic_pos = int(parts[0])
                row.pagerank_pos = int(parts[2])
            except ValueError:
                pass
            remaining -= 1
            if remaining == 0:
                break

    out = list(rows.values())
    out.sort(key=lambda r: (r.harmonic_pos is None,
                            r.harmonic_pos or 0,
                            r.domain))
    return out


# --------------------------------------------------------------------------
# GUI
# --------------------------------------------------------------------------

ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")


class App(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("Max Intel Linkgraph — referring domains from the Common Crawl webgraph")
        self.geometry("1020x680")
        self.minsize(860, 560)

        self.q: queue.Queue = queue.Queue()
        self.cancel = threading.Event()
        self.worker: threading.Thread | None = None
        self.rows: list[Row] = []
        self.releases: list[dict] = []
        self.sort_state: dict[str, bool] = {}

        self._build()
        self.after(100, self._pump)
        threading.Thread(target=self._load_releases, daemon=True).start()

    # -- layout ----------------------------------------------------------
    def _build(self):
        self.grid_columnconfigure(0, weight=1)
        self.grid_rowconfigure(3, weight=1)

        # query bar
        bar = ctk.CTkFrame(self)
        bar.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 6))
        bar.grid_columnconfigure(1, weight=1)

        ctk.CTkLabel(bar, text="Domain").grid(row=0, column=0, padx=(12, 8), pady=12)
        self.domain_entry = ctk.CTkEntry(bar, placeholder_text="example.com")
        self.domain_entry.grid(row=0, column=1, sticky="ew", pady=12)
        self.domain_entry.bind("<Return>", lambda _e: self._start())

        ctk.CTkLabel(bar, text="Graph").grid(row=0, column=2, padx=(16, 8))
        self.release_menu = ctk.CTkOptionMenu(bar, values=FALLBACK_RELEASES,
                                              width=210, command=self._on_release)
        self.release_menu.grid(row=0, column=3)

        self.go_btn = ctk.CTkButton(bar, text="Find backlinks", width=130,
                                    command=self._start)
        self.go_btn.grid(row=0, column=4, padx=12)

        # options
        opts = ctk.CTkFrame(self, fg_color="transparent")
        opts.grid(row=1, column=0, sticky="ew", padx=12)
        opts.grid_columnconfigure(3, weight=1)

        self.ranks_var = tk.BooleanVar(value=True)
        ctk.CTkCheckBox(opts, text="Attach authority ranks (+2.4 GB)",
                        variable=self.ranks_var).grid(row=0, column=0, padx=(2, 18))
        self.cache_var = tk.BooleanVar(value=True)
        ctk.CTkCheckBox(opts, text="Cache files to disk",
                        variable=self.cache_var).grid(row=0, column=1, padx=(0, 18))
        ctk.CTkButton(opts, text="Cache…", width=80, fg_color="gray30",
                      hover_color="gray25", command=self._cache_menu).grid(row=0, column=2)
        self.stats_lbl = ctk.CTkLabel(opts, text="", text_color="gray60", anchor="e")
        self.stats_lbl.grid(row=0, column=3, sticky="e", padx=6)

        # progress
        prog = ctk.CTkFrame(self)
        prog.grid(row=2, column=0, sticky="ew", padx=12, pady=10)
        prog.grid_columnconfigure(0, weight=1)

        self.phase_lbl = ctk.CTkLabel(prog, text="Idle", anchor="w",
                                      font=ctk.CTkFont(weight="bold"))
        self.phase_lbl.grid(row=0, column=0, sticky="ew", padx=12, pady=(10, 2))
        self.bar = ctk.CTkProgressBar(prog)
        self.bar.set(0)
        self.bar.grid(row=1, column=0, sticky="ew", padx=12)
        self.detail_lbl = ctk.CTkLabel(prog, text="", anchor="w", text_color="gray60")
        self.detail_lbl.grid(row=2, column=0, sticky="ew", padx=12, pady=(2, 10))

        # results table
        table = ctk.CTkFrame(self)
        table.grid(row=3, column=0, sticky="nsew", padx=12)
        table.grid_columnconfigure(0, weight=1)
        table.grid_rowconfigure(0, weight=1)

        style = ttk.Style()
        try:
            style.theme_use("clam")
        except tk.TclError:
            pass
        style.configure("Treeview", background="#212121", fieldbackground="#212121",
                        foreground="#dddddd", rowheight=24, borderwidth=0)
        style.configure("Treeview.Heading", background="#2b2b2b",
                        foreground="#bbbbbb", borderwidth=0)
        style.map("Treeview", background=[("selected", "#1f6aa5")])

        cols = ("domain", "hosts", "harmonic", "pagerank")
        self.tree = ttk.Treeview(table, columns=cols, show="headings", selectmode="browse")
        headings = {"domain": "Referring domain", "hosts": "Hosts",
                    "harmonic": "Harmonic rank", "pagerank": "PageRank rank"}
        widths = {"domain": 460, "hosts": 90, "harmonic": 130, "pagerank": 130}
        for c in cols:
            self.tree.heading(c, text=headings[c],
                              command=lambda col=c: self._sort_by(col))
            self.tree.column(c, width=widths[c],
                             anchor="w" if c == "domain" else "e")
        self.tree.grid(row=0, column=0, sticky="nsew")
        sb = ttk.Scrollbar(table, orient="vertical", command=self.tree.yview)
        sb.grid(row=0, column=1, sticky="ns")
        self.tree.configure(yscrollcommand=sb.set)
        self.tree.bind("<Double-1>", self._open_selected)

        # footer
        foot = ctk.CTkFrame(self, fg_color="transparent")
        foot.grid(row=4, column=0, sticky="ew", padx=12, pady=10)
        foot.grid_columnconfigure(0, weight=1)
        self.count_lbl = ctk.CTkLabel(foot, text="No results yet", anchor="w",
                                      text_color="gray60")
        self.count_lbl.grid(row=0, column=0, sticky="w")
        self.export_btn = ctk.CTkButton(foot, text="Export CSV", width=110,
                                        state="disabled", command=self._export)
        self.export_btn.grid(row=0, column=1)

    # -- release metadata -------------------------------------------------
    def _load_releases(self):
        try:
            data = requests.get(GRAPHINFO_URL, headers={"User-Agent": USER_AGENT},
                                timeout=30).json()
        except Exception:
            # index.commoncrawl.org 503s intermittently. The releases in
            # FALLBACK_RELEASES still resolve fine on data.commoncrawl.org, so
            # carry on with those rather than silently leaving a stub menu and
            # letting the user wonder why the list is short.
            self.q.put(("log", "Couldn't reach the Common Crawl release list — "
                               "using known releases. Lookups still work."))
            return
        self.releases = data
        self.q.put(("releases", [d["id"] for d in data]))

    def _on_release(self, release):
        info = next((d for d in self.releases if d["id"] == release), None)
        if not info:
            self.stats_lbl.configure(text="")
            return
        dom = info.get("stats", {}).get("domain", {})
        nodes, arcs = dom.get("nodes", 0), dom.get("arcs", 0)
        self.stats_lbl.configure(
            text=f"{nodes/1e6:,.1f}M domains · {arcs/1e9:,.2f}B links"
        )

    # -- cache ------------------------------------------------------------
    def _cache_size(self) -> int:
        if not CACHE_DIR.exists():
            return 0
        return sum(p.stat().st_size for p in CACHE_DIR.rglob("*") if p.is_file())

    def _cache_menu(self):
        size = self._cache_size()
        msg = f"Cache: {CACHE_DIR}\nCurrently using {human_bytes(size)}.\n\nDelete it?"
        if messagebox.askyesno("Cache", msg):
            import shutil
            shutil.rmtree(CACHE_DIR, ignore_errors=True)
            messagebox.showinfo("Cache", "Cache cleared.")

    # -- run --------------------------------------------------------------
    def _start(self):
        if self.worker and self.worker.is_alive():
            self.cancel.set()
            self.phase_lbl.configure(text="Cancelling…")
            return

        domain = normalise_domain(self.domain_entry.get())
        if not domain or "." not in domain:
            messagebox.showwarning("Domain", "Enter a domain, e.g. example.com")
            return

        spec = JobSpec(domain=domain,
                       release=self.release_menu.get(),
                       with_ranks=self.ranks_var.get(),
                       cache=self.cache_var.get())

        transfer = 14.5 + (2.2 if spec.with_ranks else 0)
        if not spec.cache:
            transfer += 0.9      # vertices gets fetched twice
        if not messagebox.askokcancel(
            "Heads up",
            f"Looking up {domain} in {spec.release}.\n\n"
            f"This streams roughly {transfer:.0f} GB from Common Crawl and filters "
            "it locally. Reckon on 10–45 minutes depending on your connection — "
            "it's download-bound, not CPU-bound.\n\n"
            f"Caching is {'on' if spec.cache else 'off'} — "
            f"{'later lookups on this graph will be much faster.' if spec.cache else 'nothing is kept on disk.'}"
            "\n\nStart?"
        ):
            return

        for item in self.tree.get_children():
            self.tree.delete(item)
        self.rows = []
        self.export_btn.configure(state="disabled")
        self.count_lbl.configure(text="Working…")
        self.cancel.clear()
        self.go_btn.configure(text="Cancel", fg_color="#8a3b3b", hover_color="#733131")

        emit = Emitter(self.q)
        self.worker = threading.Thread(target=self._work, args=(spec, emit), daemon=True)
        self.worker.start()

    def _work(self, spec: JobSpec, emit: Emitter):
        started = time.monotonic()
        try:
            rows = run_job(spec, self.cancel, emit)
            emit.log(f"Finished in {time.monotonic() - started:.0f}s")
            emit.done(rows)
        except Cancelled:
            emit.fail("Cancelled.")
        except LookupError as exc:
            emit.fail(str(exc))
        except requests.RequestException as exc:
            emit.fail(f"Network problem: {exc}")
        except Exception as exc:  # pragma: no cover
            emit.fail(f"{type(exc).__name__}: {exc}")

    # -- event pump -------------------------------------------------------
    def _pump(self):
        try:
            while True:
                kind, payload = self.q.get_nowait()
                if kind == "releases":
                    self.release_menu.configure(values=payload)
                    if payload:
                        self.release_menu.set(payload[0])
                        self._on_release(payload[0])
                elif kind == "phase":
                    self.phase_lbl.configure(text=payload)
                    self.bar.set(0)
                elif kind == "progress":
                    done, total, cached = payload
                    self.bar.set(done / total if total else 0)
                    tag = "from cache" if cached else "downloading"
                    self.detail_lbl.configure(
                        text=f"{human_bytes(done)} / {human_bytes(total)} ({tag})"
                    )
                elif kind == "log":
                    self.detail_lbl.configure(text=payload)
                elif kind == "done":
                    self._finish(payload)
                elif kind == "fail":
                    self._reset()
                    self.phase_lbl.configure(text="Stopped")
                    self.detail_lbl.configure(text="")
                    self.count_lbl.configure(text="No results")
                    if payload != "Cancelled.":
                        messagebox.showerror("Max Intel Linkgraph", payload)
        except queue.Empty:
            pass
        self.after(100, self._pump)

    def _reset(self):
        self.go_btn.configure(text="Find backlinks", fg_color=["#3a7ebf", "#1f538d"],
                              hover_color=["#325882", "#14375e"])
        self.bar.set(0)

    def _finish(self, rows: list[Row]):
        self.rows = rows
        self._reset()
        self.phase_lbl.configure(text="Done")
        self._render()
        if rows:
            self.export_btn.configure(state="normal")

    def _render(self):
        for item in self.tree.get_children():
            self.tree.delete(item)
        shown = self.rows[:MAX_DISPLAY_ROWS]
        for r in shown:
            self.tree.insert("", "end", values=(
                r.domain,
                f"{r.n_hosts:,}",
                f"{r.harmonic_pos:,}" if r.harmonic_pos else "—",
                f"{r.pagerank_pos:,}" if r.pagerank_pos else "—",
            ))
        total = len(self.rows)
        if total > MAX_DISPLAY_ROWS:
            self.count_lbl.configure(
                text=f"{total:,} referring domains — showing first {MAX_DISPLAY_ROWS:,}, "
                     "export for the full set"
            )
        else:
            self.count_lbl.configure(text=f"{total:,} referring domains")

    def _sort_by(self, col):
        rev = not self.sort_state.get(col, False)
        self.sort_state[col] = rev
        keys = {
            "domain": lambda r: r.domain,
            "hosts": lambda r: r.n_hosts,
            "harmonic": lambda r: (r.harmonic_pos is None, r.harmonic_pos or 0),
            "pagerank": lambda r: (r.pagerank_pos is None, r.pagerank_pos or 0),
        }
        self.rows.sort(key=keys[col], reverse=rev)
        self._render()

    def _open_selected(self, _event):
        sel = self.tree.selection()
        if not sel:
            return
        domain = self.tree.item(sel[0], "values")[0]
        import webbrowser
        webbrowser.open(f"https://{domain}")

    def _export(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            initialfile=f"maxintel-linkgraph-{normalise_domain(self.domain_entry.get())}.csv",
            filetypes=[("CSV", "*.csv")],
        )
        if not path:
            return
        with open(path, "w", newline="", encoding="utf-8") as fh:
            w = csv.writer(fh)
            w.writerow(["referring_domain", "hosts", "harmonic_rank", "pagerank_rank"])
            for r in self.rows:
                w.writerow([r.domain, r.n_hosts,
                            r.harmonic_pos or "", r.pagerank_pos or ""])
        self.count_lbl.configure(text=f"Exported {len(self.rows):,} rows to {path}")


if __name__ == "__main__":
    App().mainloop()
