---
# ai_support_agent role: installs this project's own CLI
# (@ai-support-agent/cli), authenticates it non-interactively via
# `ai-support-agent configure` (once per token — see "multiple tokens"
# below), and registers it as a systemd **user** service via
# `ai-support-agent service install` so it runs persistently on the target
# server (resident_agent execution path).
#
# Depends on the nvm role having already been run for the same user
# (`ai_support_agent_user` must match nvm's `nvm_user`, default 'appuser'
# for both).
#
# **Multiple tokens**: `ai_support_agent_tokens` accepts a list of
# `{token, project_code?, api_url?}` entries. The underlying CLI already
# supports registering several tenant/project pairs on one host — each
# `configure` call upserts a `ProjectRegistration` keyed by
# tenantCode+projectCode (`src/config-manager.ts`), and `service install`
# (`src/cli/service/linux-service.ts`) creates one systemd user unit per
# registered project. This role therefore runs `configure` once per list
# entry, then `service install` **once** at the end (it enumerates every
# registered project itself). The legacy singular `ai_support_agent_token`/
# `ai_support_agent_api_url`/`ai_support_agent_project_code` variables are
# still supported and merged into the same list for backward compatibility.
#
# **`ai_support_agent_api_url` is optional** for every entry: the CLI's own
# `configure` command already defaults to `DEFAULT_API_URL` (the production
# API) when `--api-url` is omitted (`src/constants.ts`), so this only needs
# to be set to point at a non-production API (e.g. an on-prem/staging
# verification environment).
#
# `configure` and `service install` are called as explicit, separate tasks
# rather than relying on `configure`'s internal auto-install
# (`installAndStartProject`), so each step's success/failure is visible
# independently in the playbook run.
#
# `service install` registers systemd **user** units
# (`~/.config/systemd/user`), which only run when either a user session is
# active or "lingering" is enabled for that user (`loginctl enable-linger`).
# On a freshly provisioned VPS there is no active login session, so linger
# must be enabled explicitly. Enabling linger alone does not guarantee an
# already-running per-user systemd instance (it normally starts at the next
# login/boot) — `user@<uid>.service` is started explicitly so
# XDG_RUNTIME_DIR (/run/user/<uid>) and the user's systemd/D-Bus instance
# exist immediately, without waiting for an actual login. Verified against a
# real systemd (Ubuntu 24.04) container: `loginctl enable-linger` +
# `systemctl start user@<uid>.service` + `XDG_RUNTIME_DIR=/run/user/<uid>`
# is sufficient for `systemctl --user` (which `service install` calls
# internally) to succeed with no active login session.
#
# **Secret handling (tokens)**: each token is never interpolated by Jinja
# into the shell script text, never passed as a `--token` CLI flag, and
# (unlike an earlier revision of this role) never passed via Ansible's
# `environment:` keyword either. This matters for three independent
# reasons:
#   1. Jinja renders `{{ ... }}` into the script BEFORE bash parses it. A
#      token containing shell metacharacters (`"`, `` ` ``, `$(...)`) would
#      be interpreted as shell syntax rather than literal text — arbitrary
#      command execution as `ai_support_agent_user`.
#   2. A `--token` CLI argument is visible to any local user via `ps`/
#      `/proc/<pid>/cmdline` (world-readable by default) for as long as the
#      process runs. `ai-support-agent configure` reads the token from the
#      `AI_SUPPORT_AGENT_TOKEN` environment variable when `--token` is
#      omitted (`agent/src/cli/auth-commands.ts`).
#   3. Ansible's `environment:` keyword is itself NOT a safe channel for a
#      secret passed to a `shell`/`command` task — verified with a real
#      `ansible-playbook -vvv` run that the connection plugin's EXEC trace
#      line prints `environment:` values in cleartext (e.g.
#      `<host> EXEC /bin/sh -c 'AI_SUPPORT_AGENT_TOKEN=... /path/to/python
#      ...'`) regardless of `no_log: true` on the task (`no_log` only
#      redacts the task's own registered result, not the connection
#      layer's own verbose trace).
# Each token is instead written via `ansible.builtin.tempfile` +
# `copy: content:` to a 0600 file owned by `ai_support_agent_user` —
# `copy`'s `content:` parameter IS correctly redacted at every verbosity
# level by the module's own argument spec, so the copy needs no task-level
# `no_log` for the content; it carries one only because it is looped, and a
# looped result echoes `item` (which holds the token). The configure task
# then reads the file back
# via `$(cat ...)`, so the Jinja-rendered script text only ever contains a
# file *path* (not a secret). The temp file is deleted at the end of its
# own script and again by an unconditional cleanup task as a safety net.
# `api_url`/`project_code` are not secrets, so they are still passed via
# `environment:` + `"$VAR"`/`${VAR:+...}` shell expansion (only to avoid
# Jinja-into-shell-text interpolation, reason 1 above — reasons 2/3 do not
# apply to non-secret values).
#
# **Diagnostics under `no_log` + `loop`**: `no_log: true` on a looped task
# censors the entire per-iteration result (including `loop_control.label`),
# so a naive loop would report only "ok" or a generic failure with zero
# detail. This role instead sets `failed_when: false` on the configure loop
# (so it always completes all iterations and Ansible's own `failed` field
# is never trusted) and inspects each iteration's raw shell exit code
# (`.rc`) in a follow-up, non-`no_log` task to report exactly which
# `project_code` entries failed — without ever printing a token.

- name: "ai_support_agent : Merge legacy single-token variable into the token list"
  ansible.builtin.set_fact:
    ai_support_agent_configure_items: >-
      {{ (ai_support_agent_tokens | default([]))
         + ([{'token': ai_support_agent_token,
              'api_url': ai_support_agent_api_url | default(''),
              'project_code': ai_support_agent_project_code | default('')}]
            if (ai_support_agent_token is defined and ai_support_agent_token is string
                and (ai_support_agent_token | trim | length > 0))
            else []) }}
  no_log: true

- name: "ai_support_agent : Validate at least one token is configured"
  ansible.builtin.assert:
    that:
      - ai_support_agent_configure_items | length > 0
    fail_msg: >-
      At least one token must be set, either via ai_support_agent_tokens
      (a list of {token, project_code?, api_url?} entries) or the legacy
      ai_support_agent_token variable.

- name: "ai_support_agent : Collect token list entries whose token is empty"
  # Aggregated here rather than asserted per item: a looped task echoes `item`
  # itself (verified — the token printed in cleartext), so the assert below used
  # to need `no_log: true`. But `no_log` replaces the whole result with
  # "censored" and drops `msg`, so its fail_msg never reached the operator — a
  # mistyped secret variable name surfaced only as "<task> failed". Reducing to
  # the offending project_codes first lets the assert run WITHOUT `no_log`,
  # naming what to fix while never touching a token value.
  ansible.builtin.set_fact:
    ai_support_agent_empty_token_entries: >-
      {{ ai_support_agent_configure_items
         | rejectattr('token', 'defined')
         | map(attribute='project_code', default='(no project_code)') | list
         + (ai_support_agent_configure_items
            | selectattr('token', 'defined')
            | rejectattr('token', 'string') | map(attribute='project_code', default='(no project_code)') | list)
         + (ai_support_agent_configure_items
            | selectattr('token', 'defined')
            | selectattr('token', 'string')
            | selectattr('token', 'match', '^\s*$')
            | map(attribute='project_code', default='(no project_code)') | list) }}

- name: "ai_support_agent : Validate every token list entry has a non-empty token"
  # No `no_log`: this assert reports only project_codes, never a token.
  ansible.builtin.assert:
    that:
      - ai_support_agent_empty_token_entries | length == 0
    fail_msg: >-
      Every ai_support_agent_tokens entry must have a non-empty 'token' field.
      These entries (by project_code) have an empty or missing token:
      {{ ai_support_agent_empty_token_entries | join(', ') }}.
      A token is usually empty because the ANSIBLE# secret variable it
      references does not exist under that exact name.

- name: "ai_support_agent : Validate nvm role has been run for this user"
  ansible.builtin.stat:
    path: "/home/{{ ai_support_agent_user | default('appuser') }}/.nvm/nvm.sh"
  register: ai_support_agent_nvm_check

- name: "ai_support_agent : Assert Node.js/npm (via nvm role) is available"
  ansible.builtin.assert:
    that:
      - ai_support_agent_nvm_check.stat.exists
    fail_msg: >-
      Node.js/npm not found for user '{{ ai_support_agent_user | default('appuser') }}'.
      Include the nvm role (with a matching nvm_user) before ai_support_agent.

- name: "ai_support_agent : Install ai-support-agent CLI via npm"
  ansible.builtin.shell: |
    set -e
    export NVM_DIR="/home/{{ ai_support_agent_user | default('appuser') }}/.nvm"
    . "$NVM_DIR/nvm.sh"
    npm install -g {{ ai_support_agent_package | default('@ai-support-agent/cli') }}
  args:
    executable: /bin/bash
  become_user: "{{ ai_support_agent_user | default('appuser') }}"
  register: ai_support_agent_install_result
  changed_when: "'up to date' not in ai_support_agent_install_result.stdout"

- name: "ai_support_agent : Resolve numeric uid for the target user"
  ansible.builtin.command:
    cmd: "id -u {{ ai_support_agent_user | default('appuser') }}"
  register: ai_support_agent_uid
  changed_when: false

- name: "ai_support_agent : Create a secure temporary file for each token"
  # Neither Ansible's `environment:` keyword nor Jinja-interpolating a token
  # into the shell script text is used to pass tokens to the configure
  # step below — see the module header comment ("Secret handling") for why
  # `environment:` leaks its values in `ansible-playbook -vvv` EXEC traces
  # regardless of `no_log`. `ansible.builtin.copy`'s `content:` parameter
  # (used in the next task), by contrast, is redacted correctly at every
  # verbosity level, so each token is written to its own 0600 temp file and
  # read back inside the shell script via `$(cat ...)` instead.
  ansible.builtin.tempfile:
    state: file
    prefix: ai_support_agent_token_
  loop: "{{ ai_support_agent_configure_items }}"
  loop_control:
    label: "(token file, redacted)"
  register: ai_support_agent_token_tempfiles
  changed_when: false

- name: "ai_support_agent : Write each token into its own temp file"
  # `no_log` is needed here even though `copy`'s `content` is redacted by the
  # module itself: a looped task echoes `item`, and `item.0` is the whole token
  # entry. It therefore hides the failure reason too, so the next task reports
  # which entries failed from the registered results (paths and indexes only).
  ansible.builtin.copy:
    content: "{{ item.0.token }}"
    dest: "{{ item.1.path }}"
    owner: "{{ ai_support_agent_user | default('appuser') }}"
    mode: '0600'
  loop: "{{ ai_support_agent_configure_items | zip(ai_support_agent_token_tempfiles.results) | list }}"
  loop_control:
    label: "(token file, redacted)"
  register: ai_support_agent_token_writes
  ignore_errors: true
  no_log: true
  changed_when: true

- name: "ai_support_agent : Fail if the token file write task itself failed (token-safe diagnostic)"
  # `ignore_errors` also swallows a failure of the task as a whole (e.g. the
  # `loop:` expression itself failing to render), and then the registered result
  # has no `.results` at all — the per-item diagnostic below would read that as
  # "zero failures" and skip. Catch that shape explicitly instead. The module
  # message describes the templating/lookup error, never a token.
  ansible.builtin.fail:
    msg: >-
      Writing the token files failed before any entry was processed:
      {{ ai_support_agent_token_writes.msg | default('(no message)') }}
  when:
    - ai_support_agent_token_writes.results is not defined
    - ai_support_agent_token_writes.failed | default(false)

- name: "ai_support_agent : Fail if any token file could not be written (token-safe diagnostic)"
  # Not `no_log`, so this message is delivered. It names the failing entries by
  # project_code and reports the module's own error (a path/permission problem),
  # never the token: the failed results are looked up by index against
  # ai_support_agent_configure_items rather than read out of `item`.
  vars:
    ai_support_agent_write_failures: >-
      {{ ai_support_agent_token_writes.results | default([])
         | selectattr('failed', 'defined') | selectattr('failed') | list }}
    # Results keep the loop's order, so zipping them back onto the entry list
    # recovers each failure's project_code without touching `item`
    # (`ansible_loop.index0` would need `loop_control: extended`, which this
    # loop does not set).
    ai_support_agent_write_failed_codes: >-
      {{ ai_support_agent_token_writes.results | default([])
         | zip(ai_support_agent_configure_items)
         | selectattr('0.failed', 'defined') | selectattr('0.failed')
         | map(attribute='1.project_code', default='(default project)') | list }}
  ansible.builtin.fail:
    msg: >-
      {{ ai_support_agent_write_failures | length }} of
      {{ ai_support_agent_configure_items | length }} token file(s) could not be
      written. Failing project_code(s): {{ ai_support_agent_write_failed_codes }}.
      First error: {{ ai_support_agent_write_failures[0].msg | default('(no message)') }}
      (token redacted by no_log).
  when: ai_support_agent_write_failures | length > 0

- name: "ai_support_agent : Configure ai-support-agent CLI for each token (non-interactive auth)"
  ansible.builtin.shell: |
    set -e
    export NVM_DIR="/home/{{ ai_support_agent_user | default('appuser') }}/.nvm"
    . "$NVM_DIR/nvm.sh"
    export AI_SUPPORT_AGENT_TOKEN="$(cat '{{ item.1.path }}')"
    ai-support-agent configure ${AI_SUPPORT_AGENT_API_URL:+--api-url "$AI_SUPPORT_AGENT_API_URL"} ${AI_SUPPORT_AGENT_PROJECT_CODE:+--project-code "$AI_SUPPORT_AGENT_PROJECT_CODE"}
    rm -f '{{ item.1.path }}'
  args:
    executable: /bin/bash
  become_user: "{{ ai_support_agent_user | default('appuser') }}"
  environment:
    AI_SUPPORT_AGENT_API_URL: "{{ item.0.api_url | default('') }}"
    AI_SUPPORT_AGENT_PROJECT_CODE: "{{ item.0.project_code | default('') }}"
  loop: "{{ ai_support_agent_configure_items | zip(ai_support_agent_token_tempfiles.results) | list }}"
  loop_control:
    label: "{{ item.0.project_code | default('(default project)') }}"
  register: ai_support_agent_configure_results
  # Always completes every iteration (never halts mid-loop on the first
  # failure) so the diagnostic task below can report every failing entry.
  # Ansible's own `failed`/`changed` fields become unreliable once forced
  # here, so the diagnostic task keys off the raw shell `.rc` instead.
  failed_when: false
  changed_when: true
  no_log: true

- name: "ai_support_agent : Remove any leftover token temp files (cleanup safety net)"
  ansible.builtin.file:
    path: "{{ item.path }}"
    state: absent
  loop: "{{ ai_support_agent_token_tempfiles.results }}"
  loop_control:
    label: "(token file, redacted)"
  changed_when: false

- name: "ai_support_agent : Fail if any configure attempt failed (token-safe diagnostic)"
  vars:
    ai_support_agent_failed_items: "{{ ai_support_agent_configure_results.results | selectattr('rc', 'defined') | selectattr('rc', 'ne', 0) | list }}"
    ai_support_agent_failed_project_codes: "{{ ai_support_agent_failed_items | map(attribute='item.0.project_code', default='(default project)') | list }}"
  ansible.builtin.fail:
    msg: >-
      {{ ai_support_agent_failed_items | length }} of
      {{ ai_support_agent_configure_items | length }} ai-support-agent
      configure attempt(s) failed. Failing project_code(s):
      {{ ai_support_agent_failed_project_codes }}
      (token and command output redacted by no_log). Check network
      reachability to api_url and that each token is valid.
  when: ai_support_agent_failed_items | length > 0

- name: "ai_support_agent : Check whether linger is already enabled"
  ansible.builtin.command:
    cmd: "loginctl show-user {{ ai_support_agent_user | default('appuser') }} --property=Linger --value"
  register: ai_support_agent_linger_status
  changed_when: false

- name: "ai_support_agent : Enable linger (keeps the user's systemd instance running without an active login session)"
  ansible.builtin.command:
    cmd: "loginctl enable-linger {{ ai_support_agent_user | default('appuser') }}"
  when: (ai_support_agent_linger_status.stdout | default('')) != 'yes'

- name: "ai_support_agent : Start the per-user systemd instance"
  ansible.builtin.systemd:
    name: "user@{{ ai_support_agent_uid.stdout }}.service"
    state: started

- name: "ai_support_agent : Register all configured projects as systemd user services"
  ansible.builtin.shell: |
    set -e
    export NVM_DIR="/home/{{ ai_support_agent_user | default('appuser') }}/.nvm"
    . "$NVM_DIR/nvm.sh"
    ai-support-agent service install
  args:
    executable: /bin/bash
  become_user: "{{ ai_support_agent_user | default('appuser') }}"
  environment:
    XDG_RUNTIME_DIR: "/run/user/{{ ai_support_agent_uid.stdout }}"
  # (Re)installs/overwrites the systemd unit files (one per registered
  # project) on every run; treated as always "changed" rather than guessed
  # at via stdout heuristics.
  changed_when: true
