#!/usr/bin/env python3
"""pgdiag - read-only PostgreSQL operational diagnostics with bounded evidence.

Collects operational evidence from a live PostgreSQL instance through the
psql client without mutating anything: server identity and version,
configuration values, connection pressure, index usage, bloat signals, WAL
archiving health, recovery and replication state, installed extensions, and
database sizes. Every psql session opens with default_transaction_read_only=on
so the server itself rejects any write attempt; the tool only ever issues
read-only statements.

The script uses the Python standard library only, and --help works with no
PostgreSQL server and no psql binary installed. Diagnostic data is emitted as
bounded JSON with --json, or as human-readable text otherwise.

Exit codes: 0 ok, 1 runtime/collection error, 2 usage error,
127 psql binary not found, 124 timeout.
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

READ_ONLY_SQL = "SET default_transaction_read_only = on"
READ_ONLY_PREFIXES = ("SELECT", "SHOW", "WITH", "EXPLAIN")
DEFAULT_TIMEOUT = 30

CHECKS = [
    {
        "name": "identity",
        "title": "Server identity and version",
        "sql": (
            "SELECT current_database(), current_user, "
            "current_setting('server_version'), "
            "current_setting('server_version_num'), "
            "pg_is_in_recovery(), pg_postmaster_start_time()"
        ),
    },
    {
        "name": "config",
        "title": "Configuration values",
        "sql": (
            "SELECT 'max_connections', current_setting('max_connections') "
            "UNION ALL SELECT 'shared_buffers', current_setting('shared_buffers') "
            "UNION ALL SELECT 'effective_cache_size', current_setting('effective_cache_size') "
            "UNION ALL SELECT 'work_mem', current_setting('work_mem') "
            "UNION ALL SELECT 'maintenance_work_mem', current_setting('maintenance_work_mem') "
            "UNION ALL SELECT 'wal_level', current_setting('wal_level') "
            "UNION ALL SELECT 'max_wal_size', current_setting('max_wal_size') "
            "UNION ALL SELECT 'checkpoint_timeout', current_setting('checkpoint_timeout') "
            "UNION ALL SELECT 'archive_mode', current_setting('archive_mode') "
            "UNION ALL SELECT 'archive_command', current_setting('archive_command') "
            "UNION ALL SELECT 'archive_timeout', current_setting('archive_timeout') "
            "UNION ALL SELECT 'max_wal_senders', current_setting('max_wal_senders') "
            "UNION ALL SELECT 'max_replication_slots', current_setting('max_replication_slots') "
            "UNION ALL SELECT 'autovacuum', current_setting('autovacuum') "
            "UNION ALL SELECT 'autovacuum_max_workers', current_setting('autovacuum_max_workers') "
            "UNION ALL SELECT 'autovacuum_naptime', current_setting('autovacuum_naptime') "
            "UNION ALL SELECT 'log_min_duration_statement', current_setting('log_min_duration_statement') "
            "UNION ALL SELECT 'log_statement', current_setting('log_statement') "
            "UNION ALL SELECT 'ssl', current_setting('ssl') "
            "UNION ALL SELECT 'track_io_timing', current_setting('track_io_timing') "
            "UNION ALL SELECT 'random_page_cost', current_setting('random_page_cost') "
            "UNION ALL SELECT 'seq_page_cost', current_setting('seq_page_cost')"
        ),
    },
    {
        "name": "connections",
        "title": "Connection activity by state",
        "sql": (
            "SELECT state, count(*) FROM pg_stat_activity "
            "GROUP BY state ORDER BY count(*) DESC"
        ),
    },
    {
        "name": "index_usage",
        "title": "Most-used indexes",
        "sql": (
            "SELECT schemaname, relname, indexrelname, idx_scan, idx_tup_read, idx_tup_fetch "
            "FROM pg_stat_user_indexes ORDER BY idx_scan DESC LIMIT 10"
        ),
    },
    {
        "name": "unused_indexes",
        "title": "Indexes with no recorded scans",
        "sql": (
            "SELECT schemaname, relname, indexrelname, idx_scan "
            "FROM pg_stat_user_indexes WHERE idx_scan = 0 "
            "ORDER BY relname, indexrelname LIMIT 10"
        ),
    },
    {
        "name": "invalid_indexes",
        "title": "Indexes marked unusable",
        "sql": (
            "SELECT c.relname AS index_name, i.indrelid::regclass AS table_name "
            "FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid "
            "WHERE NOT i.indisvalid"
        ),
    },
    {
        "name": "seq_scan_heavy",
        "title": "Tables with heavy sequential scans",
        "sql": (
            "SELECT schemaname, relname, seq_scan, seq_tup_read, idx_scan "
            "FROM pg_stat_user_tables WHERE seq_scan > 0 "
            "ORDER BY seq_tup_read DESC LIMIT 10"
        ),
    },
    {
        "name": "bloat",
        "title": "Dead-tuple accumulation signals",
        "sql": (
            "SELECT schemaname, relname, n_live_tup, n_dead_tup, "
            "round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct "
            "FROM pg_stat_user_tables WHERE n_dead_tup > 0 "
            "ORDER BY n_dead_tup DESC LIMIT 10"
        ),
    },
    {
        "name": "wal_archive",
        "title": "WAL archiving health",
        "sql": (
            "SELECT archived_count, failed_count, last_archived_wal, "
            "last_archived_time, last_failed_wal, last_failed_time "
            "FROM pg_stat_archiver"
        ),
    },
    {
        "name": "recovery",
        "title": "Recovery state",
        "sql": (
            "SELECT pg_is_in_recovery(), pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()"
        ),
    },
    {
        "name": "replication",
        "title": "Replication state",
        "sql": (
            "SELECT application_name, state, sync_state, client_addr, "
            "sent_lsn, write_lsn, flush_lsn, replay_lsn "
            "FROM pg_stat_replication"
        ),
    },
    {
        "name": "extensions",
        "title": "Installed extensions",
        "sql": "SELECT extname, extversion FROM pg_extension ORDER BY extname",
    },
    {
        "name": "databases",
        "title": "Database sizes",
        "sql": (
            "SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size "
            "FROM pg_database ORDER BY pg_database_size(datname) DESC"
        ),
    },
]

CHECKS_BY_NAME = {check["name"]: check for check in CHECKS}


def emit(payload: Dict[str, Any], as_json: bool) -> None:
    """Write a payload to stdout; JSON when --json, readable text otherwise."""
    if as_json:
        print(json.dumps(payload, indent=2, sort_keys=True))
        return
    lines: List[str] = []
    for check in payload.get("checks", []):
        lines.append(f"== {check['name']}: {check['title']} ==")
        if check["status"] == "error":
            lines.append(f"  error: {check['error']}")
            continue
        for row in check.get("rows", []):
            lines.append("  " + " | ".join(str(value) for value in row))
    if "error" in payload:
        lines.append(f"error: {payload['error']}")
    if not lines:
        lines.append("no diagnostics collected")
    print("\n".join(lines))


def locate_psql(explicit: Optional[str]) -> Optional[str]:
    """Resolve the psql binary: --psql wins, otherwise search the PATH."""
    if explicit:
        if os.path.isfile(explicit) and os.access(explicit, os.X_OK):
            return explicit
        return None
    for directory in os.environ.get("PATH", "").split(os.pathsep):
        for name in ("psql", "psql.exe"):
            candidate = os.path.join(directory, name)
            if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
                return candidate
    return None


def build_command(binary: str, args: argparse.Namespace, sql: str) -> List[str]:
    """Assemble a single-session psql command that is read-only by default."""
    command = [binary, "-X", "-q", "-A", "-t", "-F", "|"]
    if args.host:
        command += ["-h", args.host]
    if args.port:
        command += ["-p", str(args.port)]
    if args.user:
        command += ["-U", args.user]
    if args.dbname:
        command += ["-d", args.dbname]
    command += ["-c", READ_ONLY_SQL, "-c", sql]
    return command


def validate_plan_query(query: str) -> Optional[str]:
    """Return a normalized single read-only query for EXPLAIN, or None if unsafe.

    Accepts only a single statement (allowing one trailing semicolon) whose
    first keyword is a read-only prefix. The read-only session setting remains
    the authoritative guard: EXPLAIN only plans, it never executes, and any
    server-side write rejection still applies to the session.
    """
    stripped = query.strip()
    if not stripped:
        return None
    without_semicolon = stripped[:-1] if stripped.endswith(";") else stripped
    if ";" in without_semicolon:
        return None
    first_word = without_semicolon.lstrip().split(None, 1)[0].upper()
    if first_word not in READ_ONLY_PREFIXES:
        return None
    return without_semicolon


def run_check(binary: str, args: argparse.Namespace, check: Dict[str, Any]) -> Dict[str, Any]:
    """Run one diagnostic check; never allow a failure to stop the others."""
    result: Dict[str, Any] = {"name": check["name"], "title": check["title"]}
    command = build_command(binary, args, check["sql"])
    try:
        proc = subprocess.run(
            command,
            capture_output=True,
            text=True,
            timeout=args.timeout,
        )
    except subprocess.TimeoutExpired:
        result["status"] = "error"
        result["error"] = f"psql timed out after {args.timeout}s"
        return result
    if proc.returncode != 0:
        result["status"] = "error"
        result["error"] = proc.stderr.strip() or f"psql exited {proc.returncode}"
        return result
    rows = [line.split("|") for line in proc.stdout.splitlines() if line.strip()]
    result["status"] = "ok"
    result["rows"] = rows
    return result


def run_plan(binary: str, args: argparse.Namespace, query: str) -> Dict[str, Any]:
    """EXPLAIN (FORMAT JSON) a caller-supplied read-only query."""
    result: Dict[str, Any] = {"name": "plan", "title": "Query plan", "status": "ok"}
    command = build_command(binary, args, f"EXPLAIN (FORMAT JSON) {query}")
    try:
        proc = subprocess.run(
            command,
            capture_output=True,
            text=True,
            timeout=args.timeout,
        )
    except subprocess.TimeoutExpired:
        result["status"] = "error"
        result["error"] = f"psql timed out after {args.timeout}s"
        return result
    if proc.returncode != 0:
        result["status"] = "error"
        result["error"] = proc.stderr.strip() or f"psql exited {proc.returncode}"
        return result
    output = proc.stdout.strip()
    try:
        result["plan"] = json.loads(output)
    except json.JSONDecodeError:
        result["plan"] = output
    return result


def main(argv: Optional[List[str]] = None) -> int:
    parser = argparse.ArgumentParser(
        prog="pgdiag",
        description=(
            "Collect read-only PostgreSQL operational diagnostics through psql. "
            "Every session runs with default_transaction_read_only=on, so the "
            "server rejects any write attempt."
        ),
        epilog=(
            "Exit codes: 0 ok, 1 runtime/collection error, 2 usage error, "
            "127 psql binary not found, 124 timeout."
        ),
    )
    parser.add_argument("--host", help="server host (default: local socket or PGHOST)")
    parser.add_argument("--port", type=int, help="server port (default: 5432 or PGPORT)")
    parser.add_argument("--user", help="role name (default: current OS user or PGUSER)")
    parser.add_argument("--dbname", help="database to connect to (default: user name)")
    parser.add_argument(
        "--psql",
        help="path to the psql binary (default: first psql on PATH)",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="emit machine-readable JSON on stdout",
    )
    parser.add_argument(
        "--plan-for",
        metavar="QUERY",
        help=(
            "run EXPLAIN (FORMAT JSON) on a single read-only statement "
            "(SELECT, SHOW, WITH, or EXPLAIN) and add it as the plan check"
        ),
    )
    parser.add_argument(
        "--check",
        action="append",
        default=[],
        metavar="NAME",
        help=(
            "run only the named check; repeatable. Names: "
            + ", ".join(check["name"] for check in CHECKS)
        ),
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=DEFAULT_TIMEOUT,
        help="seconds to wait per psql invocation (default: %(default)s)",
    )
    parser.add_argument(
        "--version",
        action="version",
        version="pgdiag 1.0.0",
    )
    args = parser.parse_args(argv)

    binary = locate_psql(args.psql)
    if binary is None:
        emit(
            {
                "ok": False,
                "error": (
                    "psql binary not found; install the PostgreSQL client or pass "
                    "--psql with an explicit path"
                ),
            },
            args.json,
        )
        return 127

    if args.check:
        unknown = [name for name in args.check if name not in CHECKS_BY_NAME]
        if unknown:
            parser.error(f"unknown check(s): {', '.join(unknown)}")
        selected = [CHECKS_BY_NAME[name] for name in args.check]
    else:
        selected = list(CHECKS)

    results = [run_check(binary, args, check) for check in selected]

    if args.plan_for:
        query = validate_plan_query(args.plan_for)
        if query is None:
            emit(
                {
                    "ok": False,
                    "error": (
                        "--plan-for requires a single read-only statement whose first "
                        "keyword is SELECT, SHOW, WITH, or EXPLAIN"
                    ),
                },
                args.json,
            )
            return 2
        results.append(run_plan(binary, args, query))

    server: Dict[str, Any] = {}
    for result in results:
        if result["name"] == "identity" and result["status"] == "ok" and result.get("rows"):
            row = result["rows"][0]
            server = {
                "database": row[0],
                "user": row[1],
                "version": row[2],
                "version_num": row[3],
                "in_recovery": row[4],
                "started_at": row[5],
            }
            break

    ok = any(result["status"] == "ok" for result in results)
    payload: Dict[str, Any] = {
        "ok": ok,
        "psql": binary,
        "server": server,
        "checks": results,
        "generated_at": datetime.now(timezone.utc).isoformat(),
    }
    emit(payload, args.json)
    return 0 if ok else 1


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