#!/usr/bin/env python3
"""LiveTa automatic local installer. No dependency installation or public port exposure.
Package hash pinned to the official v1.6 artifact. Existing services are never removed.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import zipfile

PACKAGE_URL = 'https://www.liveta.net/downloads/LiveTa-API-Setup-1.6.zip'
PACKAGE_SHA256 = '4f125c1e70afd7521d1c84babba77cb4be43411e11501aea20a95e5cb6d98427'
PROJECT = 'liveta-teslamate'
NETWORK = PROJECT + '_default'
ROOT = Path.home() / 'LiveTa-Setup'


def docker(*args, capture=False):
    return subprocess.run(['docker', *args], check=True, text=True,
                          stdout=subprocess.PIPE if capture else None,
                          stderr=subprocess.PIPE if capture else None)


def private_write(path, text):
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, 'w') as f:
        f.write(text)


def dependencies():
    if sys.version_info < (3, 9):
        raise ValueError('Python 3.9 or later is required.')
    if not shutil.which('docker'):
        raise ValueError('Install and start Docker first: https://docs.docker.com/get-started/get-docker/')
    if docker('info', '--format', '{{.OSType}}', capture=True).stdout.strip() != 'linux':
        raise ValueError('Docker must run Linux containers.')
    version = docker('compose', 'version', '--short', capture=True).stdout.strip()
    match = re.search(r'(\d+)\.(\d+)\.(\d+)', version)
    if not match or tuple(map(int, match.groups())) < (2, 30, 0):
        raise ValueError('Docker Compose 2.30.0 or later is required.')


def extract_package(target, url=PACKAGE_URL):
    if target.exists():
        raise ValueError('Setup directory already exists. Keep it as a backup; do not overwrite private runtime files.')
    target.mkdir(mode=0o700)
    try:
        with tempfile.TemporaryDirectory() as td:
            archive = Path(td) / 'setup.zip'
            with urllib.request.urlopen(url, timeout=90) as response:
                if not response.geturl().startswith('https://'):
                    raise ValueError('Download must remain HTTPS.')
                size = 0
                with archive.open('wb') as out:
                    while True:
                        block = response.read(65536)
                        if not block:
                            break
                        size += len(block)
                        if size > 30 * 1024 * 1024:
                            raise ValueError('Unexpected package size.')
                        out.write(block)
            if hashlib.sha256(archive.read_bytes()).hexdigest() != PACKAGE_SHA256:
                raise ValueError('Package verification failed. No downloaded code was started.')
            with zipfile.ZipFile(archive) as z:
                for item in z.infolist():
                    resolved = (target / item.filename).resolve()
                    if target.resolve() not in resolved.parents or (item.external_attr >> 16) & 0o170000 == 0o120000:
                        raise ValueError('Unsafe package path.')
                    if item.file_size > 20 * 1024 * 1024:
                        raise ValueError('Unexpected package entry size.')
                z.extractall(target)
    except Exception:
        # Keep any files for diagnosis; do not remove an existing deployment.
        raise
    return target / 'LiveTa API' / 'Connector'


def fresh_compose(password, encryption_key):
    # Service layout follows the official TeslaMate Docker guide.
    # Loopback bindings deliberately keep the dashboards off the public interface.
    common = {'DATABASE_USER': 'teslamate', 'DATABASE_PASS': password,
              'DATABASE_NAME': 'teslamate', 'DATABASE_HOST': 'database'}
    return {'services': {
        'teslamate': {'image': 'teslamate/teslamate:latest', 'restart': 'unless-stopped',
                     'environment': {**common, 'ENCRYPTION_KEY': encryption_key, 'MQTT_HOST': 'mosquitto'},
                     'ports': ['127.0.0.1:4000:4000'], 'volumes': ['./import:/opt/app/import'], 'cap_drop': ['ALL']},
        'database': {'image': 'postgres:18-trixie', 'restart': 'unless-stopped',
                     'environment': {'POSTGRES_USER': 'teslamate', 'POSTGRES_PASSWORD': password, 'POSTGRES_DB': 'teslamate'},
                     'volumes': ['teslamate-db:/var/lib/postgresql']},
        'grafana': {'image': 'teslamate/grafana:latest', 'restart': 'unless-stopped',
                    'environment': common.copy(), 'ports': ['127.0.0.1:3000:3000'],
                    'volumes': ['teslamate-grafana-data:/var/lib/grafana']},
        'mosquitto': {'image': 'eclipse-mosquitto:2', 'restart': 'unless-stopped',
                      'command': 'mosquitto -c /mosquitto-no-auth.conf',
                      'volumes': ['mosquitto-conf:/mosquitto/config', 'mosquitto-data:/mosquitto/data']}
    }, 'volumes': {k: {} for k in ['teslamate-db', 'teslamate-grafana-data', 'mosquitto-conf', 'mosquitto-data']}}


class SetupError(ValueError):
    """Only deliberately credential-free messages may be displayed."""


def inspected_containers():
    ids = docker('ps', '-aq', capture=True).stdout.split()
    if not ids:
        return []
    template = '{"Id":{{json .Id}},"Name":{{json .Name}},"Config":{"Image":{{json .Config.Image}},"Labels":{{json .Config.Labels}}},"State":{"Running":{{json .State.Running}}},"NetworkSettings":{"Networks":{{json .NetworkSettings.Networks}}},"HostConfig":{"NetworkMode":{{json .HostConfig.NetworkMode}}}}'
    # Do not collect environment variables from unrelated containers.
    return [json.loads(line) for line in docker('inspect', '--format', template, *ids, capture=True).stdout.splitlines() if line.strip()]


def is_teslamate(container):
    image = container.get('Config', {}).get('Image', '').split('@')[0]
    labels = container.get('Config', {}).get('Labels') or {}
    return (image.split(':')[0].endswith('/teslamate') or image.split(':')[0] == 'teslamate'
            or labels.get('com.docker.compose.service') == 'teslamate')


def select_teslamate(containers, requested=None):
    candidates = [c for c in containers if is_teslamate(c)]
    if requested:
        candidates = [c for c in candidates if c.get('Name', '').lstrip('/') == requested or c['Id'].startswith(requested)]
        if not candidates:
            raise SetupError('The specified TeslaMate container was not found. No changes made.')
    if len(candidates) > 1:
        raise SetupError('Multiple TeslaMate installations found. Add --container NAME to select one. No changes made.')
    if candidates and not candidates[0].get('State', {}).get('Running'):
        raise SetupError('TeslaMate is stopped. Start your existing stack and retry. No changes made.')
    return candidates[0] if candidates else None


def detect_configuration(container, containers):
    if 'Env' not in container['Config']:
        container = json.loads(docker('inspect', container['Id'], capture=True).stdout)[0]
    env = dict(item.split('=', 1) for item in container['Config'].get('Env', []) if '=' in item)
    def value(key, default=''):
        return env.get(key, default)
    def checked(value):
        if not isinstance(value, str) or any(c in value for c in '\r\n\x00'):
            raise SetupError('Unsupported multiline configuration. Use the manual guide; no changes made.')
        return value
    def credential(key):
        if value(key):
            return checked(value(key))
        path = value(key + '_FILE')
        if path:
            if not path.startswith('/'):
                raise SetupError('Secret file must use an absolute container path. No changes made.')
            data = docker('exec', container['Id'], 'cat', '--', path, capture=True).stdout.rstrip('\r\n')
            if len(data) > 65536:
                raise SetupError('Unexpected secret file size. No changes made.')
            return checked(data)
        return ''
    if value('DATABASE_SOCKET_DIR') or container.get('HostConfig', {}).get('NetworkMode') == 'host':
        raise SetupError('Host networking or a database socket needs manual configuration. No changes made.')
    if value('DISABLE_MQTT', 'false').lower() in ('true', '1'):
        raise SetupError('TeslaMate MQTT is disabled. Enable it in your existing setup before retrying. No changes made.')
    if value('DATABASE_SSL', 'false').lower() not in ('false', '0', ''):
        raise SetupError('Custom database TLS needs manual configuration to preserve certificate settings. No changes made.')
    if value('MQTT_TLS_ACCEPT_INVALID_CERTS', 'false').lower() in ('true', '1'):
        raise SetupError('MQTT requires an untrusted certificate. Configure trusted TLS manually; no changes made.')
    fields = {'PGHOST': value('DATABASE_HOST'), 'PGPORT': value('DATABASE_PORT', '5432'),
              'PGDATABASE': value('DATABASE_NAME'), 'PGUSER': credential('DATABASE_USER'),
              'PGPASSWORD': credential('DATABASE_PASS'), 'PGOPTIONS': '-c default_transaction_read_only=on',
              'PGSSLMODE': 'disable', 'MQTT_HOST': value('MQTT_HOST'),
              'MQTT_PORT': value('MQTT_PORT', '8883' if value('MQTT_TLS').lower() in ('true', '1') else '1883'),
              'MQTT_USERNAME': credential('MQTT_USERNAME'), 'MQTT_PASSWORD': credential('MQTT_PASSWORD'),
              'MQTT_TLS': '1' if value('MQTT_TLS').lower() in ('true', '1') else '0'}
    namespace = value('MQTT_NAMESPACE')
    if any(c in namespace for c in '#+/'):
        raise SetupError('Unsupported MQTT namespace. Use manual configuration; no changes made.')
    fields['MQTT_TOPIC_PREFIX'] = 'teslamate/' + (namespace + '/' if namespace else '') + 'cars'
    for key in ['PGHOST', 'PGDATABASE', 'PGUSER', 'PGPASSWORD', 'MQTT_HOST']:
        if not fields[key]:
            raise SetupError('Required TeslaMate database or MQTT settings were not found. Use the manual guide; no changes made.')
    for key in ['PGPORT', 'MQTT_PORT']:
        if not fields[key].isdigit() or not 0 < int(fields[key]) <= 65535:
            raise SetupError('Invalid detected port. No changes made.')
    if any(fields[k] in ('localhost', '127.0.0.1', '::1') for k in ['PGHOST', 'MQTT_HOST']):
        raise SetupError('Loopback database or MQTT needs manual network mapping. No changes made.')
    for v in fields.values():
        checked(v)
    networks = container.get('NetworkSettings', {}).get('Networks', {})
    def host_reachable_on(host, network):
        matches = []
        for other in containers:
            for name, info in other.get('NetworkSettings', {}).get('Networks', {}).items():
                aliases = (info.get('Aliases') or []) + [other.get('Name', '').lstrip('/'), info.get('IPAddress', '')]
                if host in aliases:
                    matches.append(name)
        # External hosts are resolved by Docker on the selected network.
        return not matches or network in matches
    eligible = [n for n in networks if n not in ('host', 'none', 'bridge') and
                host_reachable_on(fields['PGHOST'], n) and host_reachable_on(fields['MQTT_HOST'], n)]
    if not eligible:
        raise SetupError('No shared Docker network reaches both database and MQTT. Use the manual guide; no changes made.')
    project = (container['Config'].get('Labels') or {}).get('com.docker.compose.project', '')
    network = project + '_default' if project + '_default' in eligible else sorted(eligible)[0]
    if not re.fullmatch(r'[A-Za-z0-9_.-]+', network):
        raise SetupError('Unsupported network name. No changes made.')
    return {'network': network, 'fields': fields}


def configure_existing(connector, detected):
    fields = detected['fields']
    private_write(connector / 'runtime/full-provider.env', ''.join(k + '=' + v + '\n' for k, v in fields.items()))
    # Keep the package helper compatible: MQTT values come from its raw env_file,
    # not a separate override that the helper might omit on later operations.
    compose = connector / 'compose.yaml'
    cleaned, count = re.subn(r'^      MQTT_[^\n]*\n', '', compose.read_text(), flags=re.M)
    if count != 6:
        raise SetupError('Unexpected package Compose layout. Files retained; no existing services changed.')
    compose.write_text(cleaned)
    private_write(connector / '.env', 'TESLAMATE_NETWORK=' + detected['network'] + '\n')
    print('Detected TeslaMate database, MQTT credentials, topic prefix and Docker network. Private settings saved locally.')


def verify_api():
    # Only read-only SQL and an MQTT CONNECT/DISCONNECT handshake; never publish.
    probe = """import os,ssl,subprocess,threading,urllib.request
assert urllib.request.urlopen('http://127.0.0.1:8080/api/health',timeout=5).status==200
r=subprocess.run(['psql','-X','-w','-At','-c',"SELECT current_setting('transaction_read_only')"],capture_output=True,text=True,timeout=10)
assert r.returncode==0 and r.stdout.strip()=='on'
import paho.mqtt.client as mqtt
ready=threading.Event();success=[]
client=mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
def connected(c,u,f,reason,props):
 success.append(reason==0);ready.set()
client.on_connect=connected
if os.environ.get('MQTT_USERNAME'):client.username_pw_set(os.environ['MQTT_USERNAME'],os.environ.get('MQTT_PASSWORD',''))
if os.environ.get('MQTT_TLS')=='1':client.tls_set_context(ssl.create_default_context())
client.connect(os.environ['MQTT_HOST'],int(os.environ.get('MQTT_PORT','1883')),keepalive=15)
client.loop_start()
assert ready.wait(10) and success==[True]
client.disconnect();client.loop_stop()
"""
    import time
    for attempt in range(12):
        try:
            docker('exec', 'livelight-connector', 'python', '-c', probe, capture=True)
            print('Verified: API healthy, database queries read-only, MQTT authentication successful.')
            return
        except subprocess.SubprocessError:
            if attempt == 11:
                raise SetupError('API verification failed. Check local Docker logs and connection settings. Files and containers were retained; pairing has not started.')
            time.sleep(5)


def configure_new(connector, root):
    password, encryption = secrets.token_hex(32), secrets.token_hex(32)
    stack = root / 'TeslaMate'
    stack.mkdir(mode=0o700)
    compose = stack / 'compose.yaml'
    private_write(compose, json.dumps(fresh_compose(password, encryption), indent=2))
    docker('compose', '-p', PROJECT, '-f', str(compose), 'up', '-d')
    private_write(connector / '.env', f'TESLAMATE_NETWORK={NETWORK}\n')
    private_write(connector / 'runtime/full-provider.env',
                  f'PGHOST=database\nPGPORT=5432\nPGDATABASE=teslamate\nPGUSER=teslamate\nPGPASSWORD={password}\nPGOPTIONS=-c default_transaction_read_only=on\n')
    print('\nTeslaMate: http://127.0.0.1:4000 — authorize your vehicle here.\nGrafana: http://127.0.0.1:3000 — change the initial admin password on first login.\nRemote server? Forward these local ports with SSH; they are not exposed publicly.')


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--mode', choices=['auto', 'existing', 'new'], default='auto')
    parser.add_argument('--check', action='store_true', help='Check prerequisites only; change nothing.')
    parser.add_argument('--container', help='Select a TeslaMate container when multiple installations exist.')
    args = parser.parse_args(argv)
    dependencies()
    if args.check:
        print('Ready: Docker Linux engine, Compose 2.30+ and Python 3.9+. No changes made.')
        return 0
    containers = inspected_containers()
    teslamate = select_teslamate(containers, args.container)
    mode = args.mode if args.mode != 'auto' else ('existing' if teslamate else 'new')
    if mode == 'new' and teslamate:
        raise SetupError('TeslaMate already exists. Use automatic or existing mode. No changes made.')
    if mode == 'existing' and not teslamate:
        raise SetupError('No TeslaMate container was detected. Use new mode on a fresh Docker host.')
    detected = detect_configuration(teslamate, containers) if mode == 'existing' else None
    names = [c.get('Name', '').lstrip('/') for c in containers]
    if 'livelight-connector' in names:
        raise ValueError('LiveTa API already exists. Reopen its local setup assistant instead; no service was changed.')
    if ROOT.exists():
        raise ValueError(f'{ROOT} already exists. Reopen its setup assistant or choose a new empty location manually. No files were overwritten.')
    print('LiveTa setup preview — macOS / Linux\nPackage: v1.6, SHA-256 verified before extraction.\nInstall location: ' + str(ROOT))
    print('This will create TeslaMate, PostgreSQL, Grafana, MQTT and LiveTa API.' if mode == 'new'
          else 'This will add LiveTa API to your existing Docker network. Existing services are kept.')
    print('No Docker installation, public port exposure, Tailscale or Cloudflare installation is performed.\nNetwork setup and phone pairing remain interactive.')
    connector = extract_package(ROOT)
    if mode == 'new':
        configure_new(connector, ROOT)
    else:
        configure_existing(connector, detected)
    compose_args = ['compose', '-p', 'liveta-api', '-f', str(connector / 'compose.yaml')]
    docker(*compose_args, 'up', '-d', '--build', 'livelight-connector')
    verify_api()
    print('\nAPI started on 127.0.0.1:8795. Opening the local connection and pairing assistant.\nKeep this terminal open. Choose Tailscale, HTTPS or Cloudflare, then scan in LiveTa.\nAn API starting successfully does not yet confirm vehicle authorization or data freshness.')
    os.chdir(connector)
    os.execv(sys.executable, [sys.executable, str(connector / 'setup/bootstrap.py')])


if __name__ == '__main__':
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        print('\nStopped. Existing files and containers were retained.')
        raise SystemExit(130)
    except SetupError as error:
        print(str(error), file=sys.stderr)
        raise SystemExit(1)
    except (OSError, ValueError, subprocess.SubprocessError, zipfile.BadZipFile):
        print('Setup stopped. Check prerequisites, network and local configuration. No credentials printed; existing services and data were retained.', file=sys.stderr)
        raise SystemExit(1)
