#!/usr/bin/env python3

import argparse
import base64
import netrc
import os
import re
import shutil
import subprocess
import sys

from datetime import date, datetime, timedelta
from html.parser import HTMLParser
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin, urlparse
from urllib.request import Request, urlopen


# ============================================================
# Webalizer column positions
# ============================================================

# Daily Webalizer table looks like:
#
# Day
# Hits   %
# Files  %
# Pages  %
# Visits %
# Sites  %
# KBytes %
#
# Hence the odd-numbered positions below.

DAILY_METRICS = {
    "hits":   (1,  "Hits"),
    "files":  (3,  "Files"),
    "pages":  (5,  "Pages"),
    "visits": (7,  "Visits"),
    "sites":  (9,  "Sites"),
    "kbytes": (11, "Data"),
}


# Webalizer summary table:
#
# Month |
# Daily Avg:
#     Hits Files Pages Visits
# Monthly Totals:
#     Sites KBytes Visits Pages Files Hits

MONTHLY_METRICS = {
    "hits":   (10, "Hits"),
    "files":  (9,  "Files"),
    "pages":  (8,  "Pages"),
    "visits": (7,  "Visits"),
    "sites":  (5,  "Sites"),
    "kbytes": (6,  "Data"),
}


MONTH_NAMES = {
    "Jan": 1,
    "Feb": 2,
    "Mar": 3,
    "Apr": 4,
    "May": 5,
    "Jun": 6,
    "Jul": 7,
    "Aug": 8,
    "Sep": 9,
    "Oct": 10,
    "Nov": 11,
    "Dec": 12,
}


# ============================================================
# HTML parser
# ============================================================

class WebalizerParser(HTMLParser):

    def __init__(self):
        super().__init__(convert_charrefs=True)

        self.tables = []
        self.table = None
        self.row = None
        self.cell = None

        self.in_h1 = False
        self.h1 = []

    def handle_starttag(self, tag, attrs):

        tag = tag.lower()

        if tag == "table":
            self.table = []

        elif tag == "tr" and self.table is not None:
            self.row = []

        elif tag in ("td", "th") and self.row is not None:
            self.cell = []

        elif tag == "h1":
            self.in_h1 = True

    def handle_data(self, data):

        if self.cell is not None:
            self.cell.append(data)

        if self.in_h1:
            self.h1.append(data)

    def handle_endtag(self, tag):

        tag = tag.lower()

        if (
            tag in ("td", "th")
            and self.cell is not None
            and self.row is not None
        ):
            text = " ".join("".join(self.cell).split())
            self.row.append(text)
            self.cell = None

        elif (
            tag == "tr"
            and self.row is not None
            and self.table is not None
        ):
            if self.row:
                self.table.append(self.row)

            self.row = None

        elif tag == "table" and self.table is not None:
            self.tables.append(self.table)
            self.table = None

        elif tag == "h1":
            self.in_h1 = False


# ============================================================
# Utility functions
# ============================================================

def int_from(text):

    cleaned = re.sub(
        r"[^0-9-]",
        "",
        text or ""
    )

    if cleaned in ("", "-"):
        return 0

    return int(cleaned)


def pct_from(text):

    match = re.search(
        r"([0-9]+(?:\.[0-9]+)?)\s*%",
        text or ""
    )

    if not match:
        return None

    return float(match.group(1))


def human(value, metric):

    if metric != "kbytes":
        return f"{int(value):,}"

    kb = float(value)

    if kb < 1024:
        return f"{kb:,.0f} KB"

    mb = kb / 1024

    if mb < 1024:
        return f"{mb:,.1f} MB"

    gb = mb / 1024

    return f"{gb:,.2f} GB"


def site_from_parser(parser):

    h1 = " ".join(
        "".join(parser.h1).split()
    )

    match = re.search(
        r"Usage Statistics for\s+(.+)$",
        h1,
        re.I,
    )

    if match:
        return match.group(1).strip()

    return None


def months_between(start, end):

    year = start.year
    month = start.month

    output = []

    while (year, month) <= (end.year, end.month):

        output.append(f"{year:04d}{month:02d}")

        if month == 12:
            year += 1
            month = 1
        else:
            month += 1

    return output


# ============================================================
# TerminalWidget
# ============================================================

def find_terminal_widget():

    requested = os.environ.get("TERMINAL_WIDGET")

    candidates = [
        requested,
        shutil.which("terminal-widget"),
        os.path.expanduser("~/bin/terminal-widget"),
        "/opt/homebrew/bin/terminal-widget",
        "/usr/local/bin/terminal-widget",
        "/Applications/TerminalWidget.app/Contents/MacOS/TerminalWidget",
    ]

    for path in candidates:

        if (
            path
            and os.path.isfile(path)
            and os.access(path, os.X_OK)
        ):
            return path

    return None


# ============================================================
# Authentication
# ============================================================

def resolve_credentials(args, base_url):

    hostname = urlparse(base_url).hostname

    username = (
        args.username
        if args.username is not None
        else os.environ.get("WEBALIZER_USERNAME")
    )

    password = (
        args.password
        if args.password is not None
        else os.environ.get("WEBALIZER_PASSWORD")
    )

    # --------------------------------------------------------
    # Password file
    # --------------------------------------------------------

    if args.password_file:

        try:
            password = (
                Path(args.password_file)
                .expanduser()
                .read_text()
                .rstrip("\r\n")
            )
        except OSError as exc:
            raise RuntimeError(
                f"Cannot read password file "
                f"{args.password_file}: {exc}"
            )

    # --------------------------------------------------------
    # ~/.netrc
    #
    # Only use it where explicit credentials have not already
    # supplied both username and password.
    # --------------------------------------------------------

    if (
        hostname
        and not (username and password)
        and not args.no_netrc
    ):

        netrc_path = (
            Path(args.netrc_file).expanduser()
            if args.netrc_file
            else Path.home() / ".netrc"
        )

        if netrc_path.exists():

            try:

                credentials = netrc.netrc(
                    str(netrc_path)
                ).authenticators(hostname)

                if credentials:

                    netrc_username, _, netrc_password = credentials

                    if not username:
                        username = netrc_username

                    if not password:
                        password = netrc_password

            except (netrc.NetrcParseError, OSError) as exc:

                print(
                    f"Warning: could not read "
                    f"{netrc_path}: {exc}",
                    file=sys.stderr,
                )

    # --------------------------------------------------------
    # Require a complete pair
    # --------------------------------------------------------

    if username and not password:
        raise RuntimeError(
            "A Webalizer username was supplied "
            "but no password was found."
        )

    if password and not username:
        raise RuntimeError(
            "A Webalizer password was supplied "
            "but no username was found."
        )

    return username, password


# ============================================================
# HTTP fetch
# ============================================================

def fetch(url, username=None, password=None):

    headers = {
        "User-Agent": (
            "webalizer-widget.py/1.0 "
            "(TerminalWidget Webalizer client)"
        ),
        "Accept": "text/html,*/*",
    }

    # --------------------------------------------------------
    # HTTP Basic Authentication
    # --------------------------------------------------------

    if username and password:

        token = base64.b64encode(
            f"{username}:{password}".encode("utf-8")
        ).decode("ascii")

        headers["Authorization"] = (
            f"Basic {token}"
        )

    request = Request(
        url,
        headers=headers,
    )

    try:

        with urlopen(
            request,
            timeout=30
        ) as response:

            charset = (
                response.headers
                .get_content_charset()
                or "utf-8"
            )

            return response.read().decode(
                charset,
                errors="replace"
            )

    except HTTPError as exc:

        if exc.code == 404:
            return None

        if exc.code == 401:
            raise RuntimeError(
                f"HTTP 401 Unauthorized while fetching {url}. "
                f"Check the Webalizer username/password."
            )

        if exc.code == 403:
            raise RuntimeError(
                f"HTTP 403 Forbidden while fetching {url}."
            )

        raise RuntimeError(
            f"HTTP {exc.code} while fetching {url}"
        )

    except URLError as exc:

        raise RuntimeError(
            f"Could not fetch {url}: {exc.reason}"
        )


# ============================================================
# Parse Webalizer monthly report
# ============================================================

def parse_month(html, ym, metric):

    parser = WebalizerParser()
    parser.feed(html)

    site = site_from_parser(parser)

    metric_index = DAILY_METRICS[
        metric
    ][0]

    daily = {}

    # --------------------------------------------------------
    # Daily statistics table
    # --------------------------------------------------------

    for table in parser.tables:

        is_daily_table = any(
            row
            and row[0].lower() == "day"
            and any(
                cell.lower() == "hits"
                for cell in row
            )
            and any(
                cell.lower() == "visits"
                for cell in row
            )
            for row in table
        )

        if not is_daily_table:
            continue

        year = int(ym[:4])
        month = int(ym[4:])

        for row in table:

            if (
                not row
                or not re.fullmatch(
                    r"\d{1,2}",
                    row[0]
                )
            ):
                continue

            day = int(row[0])

            if len(row) <= metric_index:
                continue

            try:

                d = date(
                    year,
                    month,
                    day
                )

            except ValueError:
                continue

            daily[d] = int_from(
                row[metric_index]
            )

        break

    # --------------------------------------------------------
    # Monthly total
    # --------------------------------------------------------

    total_labels = {
        "hits":   "Total Hits",
        "files":  "Total Files",
        "pages":  "Total Pages",
        "visits": "Total Visits",
        "sites":  "Total Unique Sites",
        "kbytes": "Total KBytes",
    }

    wanted = total_labels[
        metric
    ].lower()

    month_total = None

    for table in parser.tables:

        for row in table:

            if (
                len(row) >= 2
                and row[0].strip().lower()
                == wanted
            ):

                month_total = int_from(
                    row[1]
                )

                break

        if month_total is not None:
            break

    # --------------------------------------------------------
    # HTTP 404 percentage
    # --------------------------------------------------------

    error404 = None

    for table in parser.tables:

        for row in table:

            if (
                row
                and re.match(
                    r"Code\s+404\b",
                    row[0],
                    re.I,
                )
            ):

                for cell in row[1:]:

                    value = pct_from(cell)

                    if value is not None:
                        error404 = value
                        break

            if error404 is not None:
                break

        if error404 is not None:
            break

    return (
        site,
        daily,
        month_total,
        error404,
    )


# ============================================================
# Parse Webalizer summary index
# ============================================================

def parse_index(html, metric):

    parser = WebalizerParser()
    parser.feed(html)

    site = site_from_parser(parser)

    metric_index = MONTHLY_METRICS[
        metric
    ][0]

    months = []

    month_pattern = re.compile(
        r"^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
        r"\s+(\d{4})$"
    )

    for table in parser.tables:

        for row in table:

            if not row:
                continue

            match = month_pattern.match(
                row[0].strip()
            )

            if not match:
                continue

            if len(row) <= metric_index:
                continue

            month_name = match.group(1)
            year = int(match.group(2))
            month = MONTH_NAMES[month_name]

            month_date = date(
                year,
                month,
                1
            )

            value = int_from(
                row[metric_index]
            )

            months.append(
                (month_date, value)
            )

    # Webalizer normally displays newest first.
    # We want chart data oldest -> newest.

    months.sort(
        key=lambda item: item[0]
    )

    return site, months


# ============================================================
# Error widget
# ============================================================

def widget_error(
    terminal_widget,
    target,
    base_url,
    message,
):

    subprocess.run([
        terminal_widget,

        "--target",
        target,

        "--title",
        "Webalizer",

        "--icon",
        "exclamationmark.triangle.fill",

        "--text",
        message,

        "--bg",
        os.environ.get(
            "WEBALIZER_BG",
            "#10252d"
        ),

        "--fg",
        "#f6c177",

        "--action-kind",
        "open-url",

        "--action-value",
        base_url,

    ], check=False)


# ============================================================
# Render TerminalWidget chart
# ============================================================

def render_chart(
    terminal_widget,
    target,
    base_url,
    title,
    values,
    caption,
    chart_format,
):

    chart = " ".join(
        str(value)
        for value in values
    )

    command = [

        terminal_widget,

        "--target",
        target,

        "--title",
        title,

        "--title-alignment",
        "left",

        "--chart",
        chart,

        "--chart-format",
        chart_format,

        "--chart-height",
        os.environ.get(
            "WEBALIZER_CHART_HEIGHT",
            "72%"
        ),

        "--base-zero",

        "--caption-text",
        caption,

        "--timestamp",

        "--bg",
        os.environ.get(
            "WEBALIZER_BG",
            "#10252d"
        ),

        "--fg",
        os.environ.get(
            "WEBALIZER_FG",
            "#65d1c5"
        ),

        "--title-color",
        os.environ.get(
            "WEBALIZER_TITLE_COLOR",
            "#eef8f7"
        ),

        "--caption-color",
        os.environ.get(
            "WEBALIZER_CAPTION_COLOR",
            "#b6cdca"
        ),

        "--action-kind",
        "open-url",

        "--action-value",
        base_url,
    ]

    if (
        os.environ.get(
            "WEBALIZER_LABEL_Y",
            "1"
        ) != "0"
    ):
        command.append(
            "--label-y"
        )

    subprocess.run(
        command,
        check=True,
    )


# ============================================================
# Daily widget
# ============================================================

def daily_widget(
    args,
    terminal_widget,
    base_url,
    username,
    password,
):

    today = date.today()

    fetch_start = today - timedelta(
        days=args.days + 31
    )

    all_daily = {}
    month_totals = {}

    site = None
    latest_404 = None

    for ym in months_between(
        fetch_start,
        today,
    ):

        url = urljoin(
            base_url,
            f"usage_{ym}.html",
        )

        html = fetch(
            url,
            username,
            password,
        )

        if not html:
            continue

        (
            this_site,
            daily,
            month_total,
            error404,
        ) = parse_month(
            html,
            ym,
            args.metric,
        )

        if this_site:
            site = this_site

        all_daily.update(
            daily
        )

        if month_total is not None:
            month_totals[
                ym
            ] = month_total

        if error404 is not None:
            latest_404 = error404

    usable_dates = sorted(
        d
        for d in all_daily
        if d <= today
    )

    if not usable_dates:

        raise RuntimeError(
            "No daily Webalizer data found."
        )

    latest = usable_dates[-1]

    start = latest - timedelta(
        days=args.days - 1
    )

    dates = [
        start + timedelta(days=i)
        for i in range(args.days)
    ]

    values = [
        all_daily.get(d, 0)
        for d in dates
    ]

    latest_value = all_daily.get(
        latest,
        0
    )

    # --------------------------------------------------------
    # Last seven Webalizer calendar days
    # --------------------------------------------------------

    week_start = latest - timedelta(
        days=6
    )

    week_total = sum(
        value
        for d, value in all_daily.items()
        if week_start <= d <= latest
    )

    # --------------------------------------------------------
    # Current Webalizer month
    # --------------------------------------------------------

    latest_ym = latest.strftime(
        "%Y%m"
    )

    month_total = month_totals.get(
        latest_ym
    )

    if month_total is None:

        month_total = sum(
            value
            for d, value in all_daily.items()
            if (
                d.year == latest.year
                and d.month == latest.month
                and d <= latest
            )
        )

    if not site:
        site = (
            urlparse(base_url).hostname
            or "Webalizer"
        )

    site = re.sub(
        r"^www\.",
        "",
        site,
        flags=re.I,
    )

    metric_label = DAILY_METRICS[
        args.metric
    ][1]

    # --------------------------------------------------------
    # Caption
    # --------------------------------------------------------

    if latest == today:
        latest_label = "Today"
    else:
        latest_label = (
            f"{latest.day} "
            f"{latest.strftime('%b')}"
        )

    caption = (
        f"{latest_label} "
        f"{human(latest_value, args.metric)}"
        f" · 7d "
        f"{human(week_total, args.metric)}"
        f" · {latest.strftime('%b')} "
        f"{human(month_total, args.metric)}"
    )

    warn_threshold = args.warn_404

    if (
        latest_404 is not None
        and latest_404 >= warn_threshold
    ):

        caption += (
            f" · 404 "
            f"{latest_404:.1f}%"
        )

    title = (
        args.title
        or f"{site} · {metric_label} · {args.days}d"
    )

    chart_format = (
        args.chart_format
        or "area"
    )

    render_chart(
        terminal_widget,
        args.target,
        base_url,
        title,
        values,
        caption,
        chart_format,
    )


# ============================================================
# Monthly widget
# ============================================================

def monthly_widget(
    args,
    terminal_widget,
    base_url,
    username,
    password,
):

    html = fetch(
        base_url,
        username,
        password,
    )

    if not html:
        raise RuntimeError(
            "Could not read Webalizer summary page."
        )

    site, month_data = parse_index(
        html,
        args.metric,
    )

    if not month_data:
        raise RuntimeError(
            "No monthly Webalizer data found."
        )

    month_data = month_data[
        -args.months:
    ]

    values = [
        value
        for _, value in month_data
    ]

    latest_month, latest_value = (
        month_data[-1]
    )

    previous_value = (
        month_data[-2][1]
        if len(month_data) >= 2
        else None
    )

    period_total = sum(values)

    if not site:
        site = (
            urlparse(base_url).hostname
            or "Webalizer"
        )

    site = re.sub(
        r"^www\.",
        "",
        site,
        flags=re.I,
    )

    metric_label = MONTHLY_METRICS[
        args.metric
    ][1]

    today = date.today()

    if (
        latest_month.year == today.year
        and latest_month.month == today.month
    ):
        latest_label = (
            f"{latest_month.strftime('%b')} MTD"
        )
    else:
        latest_label = (
            latest_month.strftime("%b")
        )

    caption = (
        f"{latest_label} "
        f"{human(latest_value, args.metric)}"
    )

    if previous_value is not None:

        previous_month = month_data[-2][0]

        caption += (
            f" · {previous_month.strftime('%b')} "
            f"{human(previous_value, args.metric)}"
        )

    caption += (
        f" · {len(month_data)}m "
        f"{human(period_total, args.metric)}"
    )

    title = (
        args.title
        or (
            f"{site} · {metric_label}"
            f" · {len(month_data)}mo"
        )
    )

    chart_format = (
        args.chart_format
        or "bar"
    )

    render_chart(
        terminal_widget,
        args.target,
        base_url,
        title,
        values,
        caption,
        chart_format,
    )


# ============================================================
# Main
# ============================================================

def main():

    parser = argparse.ArgumentParser(
        description=(
            "Display Webalizer statistics "
            "in a TerminalWidget chart."
        )
    )

    parser.add_argument(
        "url",
        nargs="?",
        default=os.environ.get(
            "WEBALIZER_URL"
        ),
        help=(
            "Webalizer base URL, e.g. "
            "https://stats.example.org/webalizer/"
        ),
    )

    parser.add_argument(
        "--view",
        choices=[
            "daily",
            "monthly",
        ],
        default=os.environ.get(
            "WEBALIZER_VIEW",
            "daily"
        ),
        help="daily or monthly chart",
    )

    parser.add_argument(
        "--metric",
        choices=[
            "visits",
            "pages",
            "hits",
            "files",
            "sites",
            "kbytes",
        ],
        default=os.environ.get(
            "WEBALIZER_METRIC",
            "visits"
        ),
    )

    parser.add_argument(
        "--days",
        type=int,
        default=int(
            os.environ.get(
                "WEBALIZER_DAYS",
                "30"
            )
        ),
        help="number of days in daily chart",
    )

    parser.add_argument(
        "--months",
        type=int,
        default=int(
            os.environ.get(
                "WEBALIZER_MONTHS",
                "12"
            )
        ),
        help="number of months in monthly chart",
    )

    parser.add_argument(
        "--target",
        default=None,
        help="TerminalWidget target name",
    )

    parser.add_argument(
        "--title",
        default=os.environ.get(
            "WEBALIZER_TITLE"
        ),
    )

    parser.add_argument(
        "--chart-format",
        default=os.environ.get(
            "WEBALIZER_CHART_FORMAT"
        ),
    )

    parser.add_argument(
        "--warn-404",
        type=float,
        default=float(
            os.environ.get(
                "WEBALIZER_404_WARN",
                "5"
            )
        ),
        help=(
            "show 404 percentage in daily "
            "caption when at or above this value"
        ),
    )

    # --------------------------------------------------------
    # Authentication
    # --------------------------------------------------------

    parser.add_argument(
        "--username",
        default=None,
        help="HTTP Basic Authentication username",
    )

    parser.add_argument(
        "--password",
        default=None,
        help="HTTP Basic Authentication password",
    )

    parser.add_argument(
        "--password-file",
        default=None,
        help=(
            "read HTTP Basic Authentication "
            "password from this file"
        ),
    )

    parser.add_argument(
        "--netrc-file",
        default=None,
        help=(
            "use a specific netrc file instead "
            "of ~/.netrc"
        ),
    )

    parser.add_argument(
        "--no-netrc",
        action="store_true",
        help="do not look for credentials in ~/.netrc",
    )

    args = parser.parse_args()

    if not args.url:

        parser.error(
            "a Webalizer URL is required"
        )

    if args.days < 7 or args.days > 128:

        parser.error(
            "--days must be between 7 and 128"
        )

    if args.months < 2 or args.months > 128:

        parser.error(
            "--months must be between 2 and 128"
        )

    base_url = (
        args.url.rstrip("/")
        + "/"
    )

    # --------------------------------------------------------
    # Default TerminalWidget targets
    #
    # Allows the daily and monthly widgets to coexist.
    # --------------------------------------------------------

    if args.target is None:

        env_target = os.environ.get(
            "WEBALIZER_TARGET"
        )

        if env_target:
            args.target = env_target

        elif args.view == "monthly":
            args.target = "webstats-monthly"

        else:
            args.target = "webstats"

    terminal_widget = find_terminal_widget()

    if not terminal_widget:

        print(
            "terminal-widget not found. "
            "Set TERMINAL_WIDGET or install "
            "the TerminalWidget CLI.",
            file=sys.stderr,
        )

        return 2

    try:

        username, password = resolve_credentials(
            args,
            base_url,
        )

        if args.view == "monthly":

            monthly_widget(
                args,
                terminal_widget,
                base_url,
                username,
                password,
            )

        else:

            daily_widget(
                args,
                terminal_widget,
                base_url,
                username,
                password,
            )

    except RuntimeError as exc:

        print(
            f"webalizer-widget.py: {exc}",
            file=sys.stderr,
        )

        widget_error(
            terminal_widget,
            args.target,
            base_url,
            str(exc),
        )

        return 1

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
