#!/usr/bin/env python3
"""Drive the simulator for any face in this repo and bring back captures.

Sets `properties.xml` and the face's DEMO-CLOCK and DEMO-GAUGE, deploys, and
saves the frame through File > Save Screen Capture, which is the only valid
source for lit-pixel counting: a window grab goes through the 2x scaler and
desaturates.
Always-on is switched only once the app is up and is proved to have engaged
by the frame differing from the lit one. `--revert` puts the sources back.
Nothing below names a face: one is resolved from `--face` or from the folder
the caller stands in, and everything else is read out of that folder."""

import argparse
import os
import re
import subprocess
import tempfile
import time

from PIL import Image

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# The anchor comments ARE the contract. A face joins by carrying them, and
# keeps whatever it called its own variables: they are matched, not named.
CLOCK = (r'(public static var \w+ = )-?\d+(, \w+ = )-?\d+(, \w+ = )-?\d+'
         r'(;[^\n]*DEMO-CLOCK)')
GAUGE = r'(public static var \w+ = )-?\d+(, \w+ = )-?\d+(;[^\n]*DEMO-GAUGE)'

MENU = ('tell application "System Events" to tell process "simulator" to click '
        'menu item "%s" of menu 1 of menu item "Display Mode" of menu 1 '
        'of menu bar item "Settings" of menu bar 1')

# Both fields are set by VALUE and the output file is named here. A typed
# path loses keystrokes, which leaves the Go to Folder sheet standing, and a
# slash that reaches the name field is saved as a colon.
SAVE = '''
tell application "System Events" to tell process "simulator"
  set frontmost to true
  click menu item "Save Screen Capture" of menu 1 of menu bar item "File" of menu bar 1
  delay 2.5
  set w to window "Save"
  set g to splitter group 1 of w
  -- Compact after a simulator restart, and Go to Folder does not reach it
  -- there. Expanded it stays expanded.
  repeat with e in UI elements of g
    if description of e is "show more options" then
      click e
      delay 2.0
    end if
  end repeat
  keystroke "g" using {command down, shift down}
  delay 1.5
  set value of text field 1 of sheet 1 of w to "%s"
  delay 0.8
  keystroke return
  delay 1.5
  if (count of sheets of w) is not 0 then error "the Go to Folder sheet stayed open"
  set value of (first text field of g whose description is "text field") to "%s"
  delay 0.8
  click button "Save" of g
  delay 2.0
end tell
'''

# A panel or sheet left standing poisons the NEXT run, which is how three
# captures in a row were lost. Every attempt ends here, successful or not.
DISMISS = '''
tell application "System Events" to tell process "simulator"
  set frontmost to true
  set n to 0
  if exists window "Save" then
    set w to window "Save"
    repeat 3 times
      if (count of sheets of w) is 0 then exit repeat
      set n to n + 1
      try
        click button 1 of sheet 1 of w
      on error
        key code 53
      end try
      delay 0.8
    end repeat
    if exists window "Save" then
      set n to n + 1
      try
        click button "Cancel" of splitter group 1 of w
      on error
        key code 53
      end try
      delay 0.8
    end if
  end if
  return n
end tell
'''


def osa(script):
    """Runs one AppleScript and hands back (it worked, what it said)."""
    r = subprocess.run(['osascript', '-e', script],
                       capture_output=True, text=True)
    return r.returncode == 0, (r.stdout + r.stderr).strip()


# The sim is killed with -9 before every build, so it never flushes this: it
# has to be set again after each deploy, not once at the start.
BATTERY = '''
tell application "System Events" to tell process "simulator"
  set frontmost to true
  click menu item "Set Battery Status" of menu 1 of menu bar item "Settings" of menu bar 1
  delay 1.5
  set w to front window
  if (count of text fields of w) is not 2 then error "the battery sheet changed shape"
  set value of text field 1 of w to "%s"
  delay 0.3
  set value of text field 2 of w to "%s"
  delay 0.3
  click button "OK" of w
end tell
'''


BATTERY_READ = '''
tell application "System Events" to tell process "simulator"
  set frontmost to true
  click menu item "Set Battery Status" of menu 1 of menu bar item "Settings" of menu bar 1
  delay 1.5
  set w to front window
  set v to value of text field 1 of w
  click button "Cancel" of w
  return v
end tell
'''


def set_battery(pct, days=3.0):
    """The watch's own charge. A battery ICON is picked from it, so a gauge
    faked through DEMO-GAUGE would disagree with the icon beside it. Read
    back, never assumed: a menu click landing before the app is up is lost."""
    time.sleep(10)                    # docs/SIMULATOR.md: wait for the app
    ok, msg = osa(BATTERY % (float(pct), float(days)))
    if not ok:
        print('  BATTERY NOT SET:', msg)
        return False
    time.sleep(1.5)
    ok, got = osa(BATTERY_READ)
    if not ok or abs(float(got or -1) - float(pct)) > 0.5:
        print('  BATTERY DID NOT TAKE - reads %s, wanted %s' % (got, pct))
        return False
    return True


def prg_name(folder):
    """The .prg the launcher builds, which also names the sim's data files."""
    p = os.path.join(folder, 'start_simulator.command')
    if not os.path.exists(p):
        raise SystemExit('not a face folder: ' + folder)
    m = re.search(r'-o\s+"bin/(\w+)\.prg"', open(p, encoding='utf-8').read())
    if not m:
        raise SystemExit('no bin/<app>.prg line in ' + p)
    return m.group(1)


def find_dial(folder):
    """The one source file carrying the anchors, wherever a face keeps it."""
    hits = []
    for base, _, files in os.walk(os.path.join(folder, 'source')):
        for fn in sorted(files):
            p = os.path.join(base, fn)
            if not fn.endswith('.mc'):
                continue
            if 'DEMO-CLOCK' in open(p, encoding='utf-8',
                                    errors='replace').read():
                hits.append(os.path.relpath(p, folder))
    if len(hits) != 1:
        raise SystemExit('%s: want exactly one DEMO-CLOCK anchor, found %d'
                         % (os.path.basename(folder), len(hits)))
    return hits[0]


class Face(object):
    """Everything that differs between faces, read out of the face folder."""

    def __init__(self, folder):
        self.dir = os.path.abspath(folder)
        self.name = os.path.basename(self.dir)
        self.app = prg_name(self.dir)
        self.dial = find_dial(self.dir)
        self.scratch = os.path.join(tempfile.gettempdir(),
                                    self.app.lower() + '-sim')
        self.saved = os.path.join(self.scratch, 'saved')
        os.makedirs(self.scratch, exist_ok=True)

    # The two files a run rewrites. NOT `git checkout` to undo them: that
    # restores the last COMMIT and silently destroys uncommitted work in
    # them, which it did, twice, before anyone noticed.
    @property
    def touched(self):
        return (self.dial, 'resources/settings/properties.xml')


def resolve(name):
    """`--face Truss`, or the face folder the caller is standing in."""
    if name:
        return Face(os.path.join(ROOT, name))
    p = os.getcwd()
    while p != os.path.dirname(p):
        if os.path.exists(os.path.join(p, 'start_simulator.command')):
            return Face(p)
        p = os.path.dirname(p)
    raise SystemExit('no face folder here - name one with --face <Folder>')


def stash(f):
    os.makedirs(f.saved, exist_ok=True)
    for rel in f.touched:
        dst = os.path.join(f.saved, rel.replace('/', '_'))
        if not os.path.exists(dst):
            with open(dst, 'w') as fh:
                fh.write(open(os.path.join(f.dir, rel),
                              encoding='utf-8').read())


def edit(f, rel, pat, repl, what):
    """One anchored rewrite in one of the face's files, or a loud stop."""
    stash(f)
    p = os.path.join(f.dir, rel)
    s = open(p, encoding='utf-8').read()
    if not re.search(pat, s):
        raise SystemExit('%s: no %s anchor in %s' % (f.name, what, rel))
    open(p, 'w', encoding='utf-8').write(re.sub(pat, repl, s, count=1))


def set_clock(f, hms):
    h, m, s = hms if hms else (-1, 0, 0)
    edit(f, f.dial, CLOCK, r'\g<1>%d\g<2>%d\g<3>%d\g<4>' % (h, m, s),
         'DEMO-CLOCK')


def set_gauges(f, lower, upper):
    edit(f, f.dial, GAUGE, r'\g<1>%d\g<2>%d\g<3>' % (lower, upper),
         'DEMO-GAUGE')


def set_props(f, settings):
    stash(f)
    p = os.path.join(f.dir, 'resources', 'settings', 'properties.xml')
    s = open(p, encoding='utf-8').read()
    for k, v in settings.items():
        pat = r'<property id="%s" type="number">-?\d+</property>' % k
        if not re.search(pat, s):
            raise SystemExit('%s: no such property: %s' % (f.name, k))
        s = re.sub(pat, '<property id="%s" type="number">%d</property>'
                   % (k, v), s)
    open(p, 'w', encoding='utf-8').write(s)


def deploy(f, device='venu3'):
    """The sim flushes properties on exit, so the .SET has to go first."""
    subprocess.run(['pkill', '-9', '-f', 'MacOS/simulator'])
    time.sleep(1.5)
    tmp = os.environ.get('TMPDIR', '/tmp')
    for ext in ('SETTINGS/%s.SET', 'DATA/%s.DAT', 'DATA/%s.IDX'):
        try:
            os.remove(os.path.join(tmp, 'com.garmin.connectiq/GARMIN/APPS',
                                   ext % f.app.upper()))
        except OSError:
            pass
    log = os.path.join(f.scratch, 'deploy.log')
    with open(log, 'w') as fh:
        subprocess.Popen([os.path.join(f.dir, 'start_simulator.command'),
                          device], stdout=fh, stderr=subprocess.STDOUT,
                         start_new_session=True)
    for _ in range(120):
        time.sleep(3)
        txt = open(log, errors='replace').read() if os.path.exists(log) else ''
        if 'BUILD SUCCESSFUL' in txt:
            return True
        if 'ERROR' in txt:
            print(txt[-2000:])
            return False
    return False


def dismiss():
    """Closes anything the save left standing, and says so when it found it."""
    ok, n = osa(DISMISS)
    if ok and n not in ('0', ''):
        print('  a stuck save panel was dismissed (%s clicks)' % n)


def framebuffer(scratch, dest):
    """One frame, saved under the name `dest` asks for. Loud when it misses."""
    tmp = os.path.join(scratch, 'fb')
    os.makedirs(tmp, exist_ok=True)
    name = os.path.basename(dest)
    landed = os.path.join(tmp, name)
    if os.path.exists(landed):
        os.remove(landed)
    ok, msg = osa(SAVE % (tmp, name))
    for _ in range(10):
        if os.path.exists(landed) and os.path.getsize(landed):
            os.replace(landed, dest)
            dismiss()
            return True
        time.sleep(1)
    print('  SAVE FAILED:', msg if not ok else 'nothing landed at ' + landed)
    dismiss()
    return False


def capture(f, dest, mode):
    """A lit frame, or an always-on one proved to have engaged."""
    if mode != 'aod':
        time.sleep(12)
        return framebuffer(f.scratch, dest)
    time.sleep(12)   # the same settle as the lit path: 10 misses the menu
    lit = os.path.join(f.scratch, 'before-aod.png')
    if not framebuffer(f.scratch, lit):
        return False
    osa(MENU % 'Always-On')
    time.sleep(9)
    ok = framebuffer(f.scratch, dest)
    osa(MENU % 'High Power')
    time.sleep(2)
    if not ok:
        return False
    # NOT byte equality: one moved pixel passes it, and a menu click that
    # silently did nothing then ships as an always-on reading of 19.59%.
    a, b = luma(lit), luma(dest)
    if b > a * 0.75:
        print('  ALWAYS-ON DID NOT ENGAGE - %.2f%% against the lit %.2f%%'
              % (b, a))
        return False
    return True


def luma(path):
    im = Image.open(path).convert('RGB')
    px = list(im.getdata())
    n = float(len(px))
    return sum(r * .299 + g * .587 + b * .114
               for r, g, b in px) / n / 255.0 * 100.0


def revert(f):
    """Put back what THIS run found, not what the last commit holds."""
    for rel in f.touched:
        src = os.path.join(f.saved, rel.replace('/', '_'))
        if os.path.exists(src):
            with open(os.path.join(f.dir, rel), 'w') as fh:
                fh.write(open(src, encoding='utf-8').read())
            os.remove(src)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--face', default=None,
                    help='face folder, else the one the caller stands in')
    ap.add_argument('--device', default='venu3')
    ap.add_argument('--time', default=None, help='H:M:S for the DEMO-CLOCK')
    ap.add_argument('--gauge', default=None,
                    help='lower,upper percent for the DEMO-GAUGE')
    ap.add_argument('--set', action='append', default=[],
                    help='Property=value, repeatable')
    ap.add_argument('--battery', default=None, type=float,
                    help='the watch charge in percent, set after the deploy')
    ap.add_argument('--mode', default='active', choices=('active', 'aod'))
    ap.add_argument('--out', required=False, default=None)
    ap.add_argument('--revert', action='store_true')
    a = ap.parse_args()
    f = resolve(a.face)
    if a.revert:
        return revert(f)
    # A stash left by a killed run holds OLD source; reverting onto it later
    # would silently destroy newer work, and did once.
    if os.path.isdir(f.saved) and os.listdir(f.saved):
        raise SystemExit('stale stash in %s - inspect it, put back what is '
                         'yours, then delete it' % f.saved)
    if a.time:
        set_clock(f, tuple(int(x) for x in a.time.split(':')))
    if a.gauge:
        set_gauges(f, *(int(x) for x in a.gauge.split(',')))
    if a.set:
        set_props(f, {k: int(v) for k, v in (s.split('=') for s in a.set)})
    if not deploy(f, a.device):
        raise SystemExit('the deploy did not report BUILD SUCCESSFUL')
    if a.battery is not None:
        set_battery(a.battery)
    out = a.out or os.path.join(f.scratch, '%s-%s.png' % (a.device, a.mode))
    ok = capture(f, out, a.mode)
    print('  ->', out if ok else 'CAPTURE FAILED')
    # The source goes back and the binary goes away. A .prg left here carries
    # a frozen clock and frozen readings, and one reached a wrist.
    revert(f)
    try:
        os.remove(os.path.join(f.dir, 'bin', f.app + '.prg'))
    except OSError:
        pass
    if not ok:
        raise SystemExit('the capture did not land - see the reason above')


if __name__ == '__main__':
    main()
