---
# Network Connectivity Playbook
# Full mesh connectivity check: Tailscale status, ping matrix, SSH port reachability,
# HTTP endpoint checks, and per-host JSON reports.
# Usage: ansible-playbook playbooks/network_connectivity.yml
# Usage: ansible-playbook playbooks/network_connectivity.yml -e "host_target=synology"

- name: Network Connectivity Check
  hosts: "{{ host_target | default('active') }}"
  gather_facts: yes
  ignore_unreachable: true

  vars:
    ntfy_url: "{{ ntfy_url | default('https://ntfy.sh/REDACTED_TOPIC') }}"
    report_dir: "/tmp/connectivity_reports"
    ts_candidates:
      - /usr/bin/tailscale
      - /var/packages/Tailscale/target/bin/tailscale
    http_endpoints:
      - name: Portainer
        url: "http://100.67.40.126:9000"
      - name: Gitea
        url: "http://100.67.40.126:3000"
      - name: Immich
        url: "http://100.67.40.126:2283"
      - name: Home Assistant
        url: "http://100.112.186.90:8123"

  tasks:

    # ---------- Setup ----------

    - name: Create connectivity report directory
      ansible.builtin.file:
        path: "{{ report_dir }}"
        state: directory
        mode: '0755'
      delegate_to: localhost
      run_once: true

    # ---------- Tailscale detection ----------

    - name: Detect Tailscale binary path (first candidate that exists)
      ansible.builtin.shell: |
        for p in {{ ts_candidates | join(' ') }}; do
          [ -x "$p" ] && echo "$p" && exit 0
        done
        echo ""
      register: ts_bin
      changed_when: false
      failed_when: false

    - name: Get Tailscale status JSON (if binary found)
      ansible.builtin.command: "{{ ts_bin.stdout }} status --json"
      register: ts_status_raw
      changed_when: false
      failed_when: false
      when: ts_bin.stdout | length > 0

    - name: Parse Tailscale status JSON
      ansible.builtin.set_fact:
        ts_parsed: "{{ ts_status_raw.stdout | from_json }}"
      when:
        - ts_bin.stdout | length > 0
        - ts_status_raw.rc is defined
        - ts_status_raw.rc == 0
        - ts_status_raw.stdout | length > 0
        - ts_status_raw.stdout is search('{')

    - name: Extract Tailscale BackendState and first IP
      ansible.builtin.set_fact:
        ts_backend_state: "{{ ts_parsed.BackendState | default('unknown') }}"
        ts_first_ip: "{{ (ts_parsed.Self.TailscaleIPs | default([]))[0] | default('n/a') }}"
      when: ts_parsed is defined

    - name: Set Tailscale defaults when binary not found or parse failed
      ansible.builtin.set_fact:
        ts_backend_state: "{{ ts_backend_state | default('not_installed') }}"
        ts_first_ip: "{{ ts_first_ip | default('n/a') }}"

    # ---------- Ping matrix (all active hosts except self) ----------

    - name: Ping all other active hosts (2 pings, 2s timeout)
      ansible.builtin.command: >
        ping -c 2 -W 2 {{ hostvars[item]['ansible_host'] }}
      register: ping_results
      loop: "{{ groups['active'] | difference([inventory_hostname]) }}"
      loop_control:
        label: "{{ item }} ({{ hostvars[item]['ansible_host'] }})"
      changed_when: false
      failed_when: false

    - name: Build ping summary map
      ansible.builtin.set_fact:
        ping_map: >-
          {{
            ping_map | default({}) | combine({
              item.item: {
                'host': hostvars[item.item]['ansible_host'],
                'rc': item.rc,
                'status': 'OK' if item.rc == 0 else 'FAIL'
              }
            })
          }}
      loop: "{{ ping_results.results }}"
      loop_control:
        label: "{{ item.item }}"

    - name: Identify failed ping targets
      ansible.builtin.set_fact:
        failed_ping_peers: >-
          {{
            ping_results.results
            | selectattr('rc', 'ne', 0)
            | map(attribute='item')
            | list
          }}

    # ---------- SSH port reachability ----------

    - name: Check SSH port reachability for all other active hosts
      ansible.builtin.command: >
        nc -z -w 3
        {{ hostvars[item]['ansible_host'] }}
        {{ hostvars[item]['ansible_port'] | default(22) }}
      register: ssh_results
      loop: "{{ groups['active'] | difference([inventory_hostname]) }}"
      loop_control:
        label: "{{ item }} ({{ hostvars[item]['ansible_host'] }}:{{ hostvars[item]['ansible_port'] | default(22) }})"
      changed_when: false
      failed_when: false

    - name: Build SSH reachability summary map
      ansible.builtin.set_fact:
        ssh_map: >-
          {{
            ssh_map | default({}) | combine({
              item.item: {
                'host': hostvars[item.item]['ansible_host'],
                'port': hostvars[item.item]['ansible_port'] | default(22),
                'rc': item.rc,
                'status': 'OK' if item.rc == 0 else 'FAIL'
              }
            })
          }}
      loop: "{{ ssh_results.results }}"
      loop_control:
        label: "{{ item.item }}"

    # ---------- Per-host connectivity summary ----------

    - name: Display per-host connectivity summary
      ansible.builtin.debug:
        msg: |
          ==========================================
          CONNECTIVITY SUMMARY: {{ inventory_hostname }}
          ==========================================
          Tailscale:
            binary:        {{ ts_bin.stdout if ts_bin.stdout | length > 0 else 'not found' }}
            backend_state: {{ ts_backend_state }}
            first_ip:      {{ ts_first_ip }}

          Ping matrix (from {{ inventory_hostname }}):
          {% for peer, result in (ping_map | default({})).items() %}
            {{ peer }} ({{ result.host }}): {{ result.status }}
          {% endfor %}

          SSH port reachability (from {{ inventory_hostname }}):
          {% for peer, result in (ssh_map | default({})).items() %}
            {{ peer }} ({{ result.host }}:{{ result.port }}): {{ result.status }}
          {% endfor %}
          ==========================================

    # ---------- HTTP endpoint checks (run once from localhost) ----------

    - name: Check HTTP endpoints
      ansible.builtin.uri:
        url: "{{ item.url }}"
        method: GET
        status_code: [200, 301, 302, 401, 403]
        timeout: 10
        validate_certs: false
      register: http_results
      loop: "{{ http_endpoints }}"
      loop_control:
        label: "{{ item.name }} ({{ item.url }})"
      delegate_to: localhost
      run_once: true
      failed_when: false

    - name: Display HTTP endpoint results
      ansible.builtin.debug:
        msg: |
          ==========================================
          HTTP ENDPOINT RESULTS
          ==========================================
          {% for result in http_results.results %}
          {{ result.item.name }} ({{ result.item.url }}):
            status: {{ result.status | default('UNREACHABLE') }}
            ok:     {{ 'YES' if result.status is defined and result.status in [200, 301, 302, 401, 403] else 'NO' }}
          {% endfor %}
          ==========================================
      delegate_to: localhost
      run_once: true

    # ---------- ntfy alert for failed ping peers ----------

    - name: Send ntfy alert when peers fail ping
      ansible.builtin.uri:
        url: "{{ ntfy_url }}"
        method: POST
        body: |
          Host {{ inventory_hostname }} detected {{ failed_ping_peers | length }} unreachable peer(s):
          {% for peer in failed_ping_peers %}
          - {{ peer }} ({{ hostvars[peer]['ansible_host'] }})
          {% endfor %}
          Checked at {{ ansible_date_time.iso8601 }}
        headers:
          Title: "Homelab Network Alert"
          Priority: "high"
          Tags: "warning,network"
        status_code: [200, 204]
      delegate_to: localhost
      failed_when: false
      when: failed_ping_peers | default([]) | length > 0

    # ---------- Per-host JSON report ----------

    - name: Write per-host JSON connectivity report
      ansible.builtin.copy:
        content: "{{ {'timestamp': ansible_date_time.iso8601, 'hostname': inventory_hostname, 'tailscale': {'binary': ts_bin.stdout | default('') | trim, 'backend_state': ts_backend_state, 'first_ip': ts_first_ip}, 'ping_matrix': ping_map | default({}), 'ssh_reachability': ssh_map | default({}), 'failed_ping_peers': failed_ping_peers | default([])} | to_nice_json }}"
        dest: "{{ report_dir }}/{{ inventory_hostname }}_{{ ansible_date_time.date }}.json"
      delegate_to: localhost
      changed_when: false
