#!/usr/bin/env python3

"""Collect availability metrics for https://mithal.space every minute."""

from __future__ import annotations

import argparse
import json
import socket
import ssl
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
import tempfile
from urllib import parse, request


TARGET_URL = "https://mithal.space"
METRICS_FILE = Path(__file__).with_name("metrics.json")
USER_AGENT = "Mozilla/5.0 (compatible; MithalMonitor/1.0)"


@dataclass
class SearchConfig:
    """Discovered search request details from the homepage."""

    action_url: str
    method: str
    query_param: str
    fixed_params: dict[str, str]


class SearchFormParser(HTMLParser):
    """Extract candidate search forms from the homepage HTML."""

    def __init__(self) -> None:
        super().__init__()
        self.forms: list[dict[str, Any]] = []
        self._current_form: dict[str, Any] | None = None

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        attributes = {key.lower(): value for key, value in attrs}

        if tag.lower() == "form":
            self._current_form = {
                "action": attributes.get("action", ""),
                "method": (attributes.get("method") or "GET").upper(),
                "inputs": [],
            }
            return

        if self._current_form is not None and tag.lower() == "input":
            self._current_form["inputs"].append(
                {
                    "type": (attributes.get("type") or "text").lower(),
                    "name": attributes.get("name", ""),
                    "value": attributes.get("value", ""),
                }
            )

    def handle_endtag(self, tag: str) -> None:
        if tag.lower() == "form" and self._current_form is not None:
            self.forms.append(self._current_form)
            self._current_form = None


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def http_get(url: str, timeout: int = 20) -> tuple[float, int, bytes]:
    """Perform a full HTTP GET and return latency, status code, and body."""

    started_at = time.perf_counter()
    req = request.Request(url, headers={"User-Agent": USER_AGENT})

    with request.urlopen(req, timeout=timeout) as response:
        body = response.read()
        latency_ms = (time.perf_counter() - started_at) * 1000
        return latency_ms, int(response.status), body


def fetch_homepage_html(base_url: str) -> str:
    req = request.Request(base_url, headers={"User-Agent": USER_AGENT})
    with request.urlopen(req, timeout=20) as response:
        return response.read().decode("utf-8", errors="replace")


def discover_search_config(base_url: str) -> SearchConfig:
    """Inspect the homepage and derive the real search request shape."""

    homepage_html = fetch_homepage_html(base_url)
    parser = SearchFormParser()
    parser.feed(homepage_html)

    for form in parser.forms:
        action = (form.get("action") or "").strip()
        method = (form.get("method") or "GET").upper()
        inputs = form.get("inputs") or []

        query_input = None
        fixed_params: dict[str, str] = {}

        for input_item in inputs:
            input_name = (input_item.get("name") or "").strip()
            input_type = (input_item.get("type") or "text").lower()
            input_value = input_item.get("value") or ""

            if not input_name:
                continue

            if input_type in {"text", "search", "url", "email", "tel", "hidden"} and query_input is None:
                if input_type != "hidden":
                    query_input = input_name

            if input_type == "hidden" and input_value:
                fixed_params[input_name] = input_value

        if query_input is None:
            for input_item in inputs:
                input_name = (input_item.get("name") or "").strip()
                input_type = (input_item.get("type") or "text").lower()
                if input_name and input_type != "hidden":
                    query_input = input_name
                    break

        if query_input and ("search" in action.lower() or any("search" in (item.get("name") or "").lower() for item in inputs)):
            return SearchConfig(
                action_url=parse.urljoin(base_url, action or "/search"),
                method=method,
                query_param=query_input,
                fixed_params=fixed_params,
            )

    return SearchConfig(
        action_url=parse.urljoin(base_url, "/search"),
        method="GET",
        query_param="q",
        fixed_params={},
    )


def measure_dns_lookup(hostname: str) -> float:
    started_at = time.perf_counter()
    socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM)
    return (time.perf_counter() - started_at) * 1000


def measure_ssl_certificate(hostname: str, port: int = 443) -> tuple[bool, str]:
    pem_certificate = ssl.get_server_certificate((hostname, port), timeout=20)

    with tempfile.NamedTemporaryFile("w", delete=True) as temp_file:
        temp_file.write(pem_certificate)
        temp_file.flush()
        certificate = ssl._ssl._test_decode_cert(temp_file.name)  # type: ignore[attr-defined]

    not_after = certificate.get("notAfter")
    if not not_after:
        raise ValueError("SSL certificate does not expose an expiration date")

    expiry_timestamp = ssl.cert_time_to_seconds(not_after)
    expiry_dt = datetime.fromtimestamp(expiry_timestamp, tz=timezone.utc)
    is_valid = datetime.now(timezone.utc) < expiry_dt
    return is_valid, expiry_dt.isoformat()


def measure_search_response(base_url: str, query: str = "test") -> float:
    search_config = discover_search_config(base_url)
    params = dict(search_config.fixed_params)
    params[search_config.query_param] = query

    if search_config.method != "GET":
        raise ValueError(f"Unsupported search method: {search_config.method}")

    search_url = f"{search_config.action_url}?{parse.urlencode(params)}"
    latency_ms, _, _ = http_get(search_url)
    return latency_ms


def metric_record(name: str, value: Any, error_message: str | None = None) -> dict[str, Any]:
    record = {name: value}
    if error_message is not None:
        record[f"{name}_error"] = error_message
    return record


def collect_metrics(base_url: str = TARGET_URL) -> dict[str, Any]:
    hostname = parse.urlparse(base_url).hostname or "mithal.space"
    record: dict[str, Any] = {"timestamp": now_iso()}

    try:
        latency_ms, status_code, _ = http_get(base_url)
        record.update(metric_record("latency_ms", round(latency_ms, 2)))
        record.update(metric_record("status_code", status_code))
        record["uptime"] = status_code == 200
    except Exception as exc:  # noqa: BLE001 - keep the monitor resilient.
        record.update(metric_record("latency_ms", None, str(exc)))
        record.update(metric_record("status_code", None, str(exc)))
        record["uptime"] = None
        record["uptime_error"] = str(exc)

    try:
        ssl_valid, ssl_expiry = measure_ssl_certificate(hostname)
        record["ssl_valid"] = ssl_valid
        record["ssl_expiry"] = ssl_expiry
    except Exception as exc:  # noqa: BLE001 - keep the monitor resilient.
        record["ssl_valid"] = None
        record["ssl_valid_error"] = str(exc)
        record["ssl_expiry"] = None
        record["ssl_expiry_error"] = str(exc)

    try:
        dns_lookup_ms = measure_dns_lookup(hostname)
        record["dns_lookup_ms"] = round(dns_lookup_ms, 2)
    except Exception as exc:  # noqa: BLE001 - keep the monitor resilient.
        record["dns_lookup_ms"] = None
        record["dns_lookup_ms_error"] = str(exc)

    try:
        search_response_ms = measure_search_response(base_url)
        record["search_response_ms"] = round(search_response_ms, 2)
    except Exception as exc:  # noqa: BLE001 - keep the monitor resilient.
        record["search_response_ms"] = None
        record["search_response_ms_error"] = str(exc)

    return record


def load_metrics(path: Path) -> list[dict[str, Any]]:
    if not path.exists():
        return []

    try:
        with path.open("r", encoding="utf-8") as file_handle:
            data = json.load(file_handle)
    except Exception:
        return []

    if isinstance(data, list):
        return [record for record in data if isinstance(record, dict)]

    return []


def save_metrics(path: Path, records: list[dict[str, Any]]) -> None:
    temp_path = path.with_suffix(".tmp")
    with temp_path.open("w", encoding="utf-8") as file_handle:
        json.dump(records, file_handle, ensure_ascii=False, indent=2)
        file_handle.write("\n")
    temp_path.replace(path)


def append_metric_record(record: dict[str, Any], path: Path = METRICS_FILE) -> None:
    records = load_metrics(path)
    records.append(record)
    save_metrics(path, records)


def run_monitor(interval_seconds: int, once: bool, target_url: str, metrics_file: Path) -> None:
    while True:
        record = collect_metrics(target_url)
        append_metric_record(record, metrics_file)
        print(json.dumps(record, ensure_ascii=False))

        if once:
            return

        time.sleep(interval_seconds)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Collect monitoring metrics for mithal.space")
    parser.add_argument("--interval", type=int, default=60, help="Seconds between metric collections")
    parser.add_argument("--once", action="store_true", help="Collect one record and exit")
    parser.add_argument("--target-url", default=TARGET_URL, help="Website to monitor")
    parser.add_argument("--metrics-file", default=str(METRICS_FILE), help="Path to metrics.json")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    metrics_file = Path(args.metrics_file)

    try:
        run_monitor(args.interval, args.once, args.target_url, metrics_file)
    except KeyboardInterrupt:
        return 0
    except Exception as exc:  # noqa: BLE001 - the outer loop should never crash silently.
        print(f"monitor failed: {exc}", file=sys.stderr)
        return 1

    return 0


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