"""
Deploy staged pipeline changes to AD2:C:\\Shares\\testdatadb\\.

Backs up each existing target to <name>.bak-YYYYMMDD before overwriting.
Fails if a target file does not exist on AD2 (excluding brand-new files
declared in NEW_FILES below).

Usage:
  python deploy-to-ad2.py --dry-run
  python deploy-to-ad2.py

Credentials: fetched at runtime from the SOPS vault
(clients/dataforth/ad2.sops.yaml -> credentials.password). No hardcoded
password; no env-var / prompt fallback. Fails loud if the vault read fails.
"""
from __future__ import annotations

import argparse
import datetime
import os
import subprocess
import sys

import paramiko

HOST = '192.168.0.6'
USER = 'sysadmin'

VAULT_SH = 'D:/vault/scripts/vault.sh'
VAULT_ENTRY = 'clients/dataforth/ad2.sops.yaml'
VAULT_FIELD = 'credentials.password'

SMTP_VAULT_ENTRY = 'clients/dataforth/m365.sops.yaml'
SMTP_VAULT_FIELD = 'credentials.password'
SMTP_USER = 'sysadmin@dataforth.com'
SMTP_HOST = 'smtp.office365.com'
SMTP_PORT = 587
NOTIFY_TO = 'mike@azcomputerguru.com'


def get_ad2_password() -> str:
    """Fetch the AD2 sysadmin password from the SOPS vault.

    Fails loud (raises) on any error: missing vault, decryption failure,
    empty value. Do NOT fall back to env vars or prompts -- per CLAUDE.md
    deploy scripts must not hold credentials.
    """
    try:
        result = subprocess.run(
            ['bash', VAULT_SH, 'get-field', VAULT_ENTRY, VAULT_FIELD],
            capture_output=True, text=True, timeout=30, check=False,
        )
    except FileNotFoundError as e:
        raise RuntimeError(
            f'[FAIL] vault helper not runnable: {VAULT_SH} ({e})'
        ) from e
    except subprocess.TimeoutExpired as e:
        raise RuntimeError(
            f'[FAIL] vault read timed out after 30s for {VAULT_ENTRY}'
        ) from e

    if result.returncode != 0:
        stderr = (result.stderr or '').strip()
        raise RuntimeError(
            f'[FAIL] vault read failed (rc={result.returncode}) for '
            f'{VAULT_ENTRY}:{VAULT_FIELD}: {stderr}'
        )

    pwd = (result.stdout or '').strip()
    if not pwd:
        raise RuntimeError(
            f'[FAIL] vault returned empty value for {VAULT_ENTRY}:{VAULT_FIELD}'
        )
    return pwd


def get_smtp_password() -> str:
    try:
        result = subprocess.run(
            ['bash', VAULT_SH, 'get-field', SMTP_VAULT_ENTRY, SMTP_VAULT_FIELD],
            capture_output=True, text=True, timeout=30, check=False,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired) as e:
        raise RuntimeError(f'[FAIL] vault read failed for SMTP creds: {e}') from e

    if result.returncode != 0:
        raise RuntimeError(
            f'[FAIL] vault read failed (rc={result.returncode}) for '
            f'{SMTP_VAULT_ENTRY}:{SMTP_VAULT_FIELD}: {result.stderr.strip()}'
        )

    pwd = (result.stdout or '').strip().replace('\\', '')
    if not pwd:
        raise RuntimeError(f'[FAIL] vault returned empty SMTP password')
    return pwd

REMOTE_ROOT = 'C:/Shares/testdatadb'
LOCAL_ROOT = os.path.dirname(os.path.abspath(__file__))

# ---------------------------------------------------------------------------
# Deployment file lists. Each list has different semantics:
#
# UPDATE_FILES -- file MUST already exist on AD2. Backup-then-overwrite.
#                 Fails loud if the remote file is missing (that's a drift
#                 signal -- something changed on the box we didn't expect).
#
# NEW_FILES    -- file must NOT already exist on AD2. Creates it.
#                 Fails loud if the remote file is already present (we would
#                 otherwise silently clobber something we didn't back up).
# ---------------------------------------------------------------------------

# Files that already exist on AD2 and will be backed up + overwritten.
UPDATE_FILES = [
    ('parsers/spec-reader.js',          'parsers/spec-reader.js'),
    ('templates/datasheet-exact.js',    'templates/datasheet-exact.js'),
    ('database/import.js',              'database/import.js'),
    ('database/export-datasheets.js',   'database/export-datasheets.js'),
]

# Files that do NOT yet exist on AD2 and must be created fresh.
NEW_FILES = [
    ('parsers/vaslog-engtxt.js',        'parsers/vaslog-engtxt.js'),
    ('server/notify.js',                'server/notify.js'),
]


def connect() -> paramiko.SSHClient:
    pwd = get_ad2_password()
    c = paramiko.SSHClient()
    c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    c.connect(
        HOST, username=USER, password=pwd,
        timeout=15, look_for_keys=False, allow_agent=False, banner_timeout=30,
    )
    return c


def remote_exists(sftp: paramiko.SFTPClient, path: str) -> bool:
    try:
        sftp.stat(path)
        return True
    except IOError:
        return False


def to_remote(rel: str) -> str:
    return f'{REMOTE_ROOT}/{rel}'


def backup_and_copy(sftp: paramiko.SFTPClient, ssh: paramiko.SSHClient,
                    local_rel: str, remote_rel: str, dry_run: bool, stamp: str) -> None:
    local_path = os.path.join(LOCAL_ROOT, local_rel.replace('/', os.sep))
    remote_path = to_remote(remote_rel)
    backup_path = f'{remote_path}.bak-{stamp}'

    if not os.path.isfile(local_path):
        raise FileNotFoundError(f'[FAIL] local file missing: {local_path}')

    if not remote_exists(sftp, remote_path):
        raise FileNotFoundError(f'[FAIL] remote file missing on AD2: {remote_path}')

    print(f'[INFO] {remote_rel}')
    if dry_run:
        print(f'       would back up to: {backup_path}')
        print(f'       would upload:      {local_path} -> {remote_path}')
        return

    # Backup via SFTP copy (read + re-upload). Paramiko has no server-side copy.
    with sftp.open(remote_path, 'rb') as src:
        data = src.read()
    with sftp.open(backup_path, 'wb') as dst:
        dst.write(data)
    print(f'       backup:  {backup_path}  ({len(data)} bytes)')

    sftp.put(local_path, remote_path)
    size = os.path.getsize(local_path)
    print(f'       uploaded: {local_path} -> {remote_path}  ({size} bytes)')


def create_new(sftp: paramiko.SFTPClient, local_rel: str, remote_rel: str,
               dry_run: bool) -> None:
    """Create a file that is expected to be NEW on AD2.

    Fails loud if the remote file already exists -- NEW_FILES declares this
    is a brand-new file, so pre-existence is a drift signal. If a previous
    deploy partially ran, clean up manually or move the entry to
    UPDATE_FILES.
    """
    local_path = os.path.join(LOCAL_ROOT, local_rel.replace('/', os.sep))
    remote_path = to_remote(remote_rel)

    if not os.path.isfile(local_path):
        raise FileNotFoundError(f'[FAIL] local file missing: {local_path}')

    print(f'[INFO] {remote_rel} (NEW)')

    if remote_exists(sftp, remote_path):
        raise FileExistsError(
            f'[FAIL] remote target already exists but is declared NEW: {remote_path} '
            f'-- move to UPDATE_FILES or remove remote manually'
        )

    if dry_run:
        print(f'       would create:  {local_path} -> {remote_path}')
        return

    sftp.put(local_path, remote_path)
    size = os.path.getsize(local_path)
    print(f'       created: {remote_path}  ({size} bytes)')


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument('--dry-run', action='store_true', help='print actions without writing')
    args = ap.parse_args()

    stamp = datetime.date.today().strftime('%Y%m%d')

    print('=' * 72)
    print('Deploy staged pipeline changes to AD2')
    print('=' * 72)
    print(f'Host:        {HOST}')
    print(f'Remote root: {REMOTE_ROOT}')
    print(f'Local root:  {LOCAL_ROOT}')
    print(f'Dry run:     {args.dry_run}')
    print(f'Backup tag:  .bak-{stamp}')
    print('')

    smtp_pass = get_smtp_password()

    ssh = connect()
    try:
        sftp = ssh.open_sftp()
        try:
            for local_rel, remote_rel in UPDATE_FILES:
                backup_and_copy(sftp, ssh, local_rel, remote_rel, args.dry_run, stamp)

            for local_rel, remote_rel in NEW_FILES:
                create_new(sftp, local_rel, remote_rel, args.dry_run)

            # Write notify config (creds fetched from vault, never committed to git)
            import json
            notify_cfg = json.dumps({
                'smtp': {
                    'host': SMTP_HOST,
                    'port': SMTP_PORT,
                    'user': SMTP_USER,
                    'pass': smtp_pass,
                },
                'from': SMTP_USER,
                'to':   NOTIFY_TO,
            }, indent=2)
            notify_remote = f'{REMOTE_ROOT}/config/notify.json'
            print(f'[INFO] config/notify.json (SMTP creds)')
            if not args.dry_run:
                # Ensure config dir exists
                stdin, stdout, stderr = ssh.exec_command(
                    f'powershell -Command "New-Item -ItemType Directory -Force -Path '
                    f'C:\\Shares\\testdatadb\\config | Out-Null"'
                )
                stdout.channel.recv_exit_status()
                with sftp.open(notify_remote, 'w') as f:
                    f.write(notify_cfg)
                print(f'       written: {notify_remote}')
            else:
                print(f'       would write: {notify_remote}')

        finally:
            sftp.close()

        # Install nodemailer if not already present
        print('[INFO] npm install nodemailer')
        if not args.dry_run:
            cmd = 'cd C:\\Shares\\testdatadb && npm list nodemailer --depth=0 2>nul || npm install nodemailer'
            stdin, stdout, stderr = ssh.exec_command(f'cmd /c "{cmd}"')
            exit_code = stdout.channel.recv_exit_status()
            out = stdout.read().decode(errors='replace').strip()
            if out:
                print(f'       {out}')
            if exit_code != 0:
                err = stderr.read().decode(errors='replace').strip()
                raise RuntimeError(f'[FAIL] npm install nodemailer failed: {err}')
            print('[OK] nodemailer ready')
        else:
            print('       would run: npm install nodemailer (if not already installed)')

    finally:
        ssh.close()

    print('')
    print('[OK] done' if not args.dry_run else '[OK] dry-run complete (no changes made)')
    return 0


if __name__ == '__main__':
    try:
        sys.exit(main())
    except Exception as e:
        print(f'[FAIL] {e}', file=sys.stderr)
        sys.exit(1)
