#!/usr/bin/env python3
"""Scan-Helfer fuer den PDF Editor (anivia.ch/pdf).

Eine Webseite darf Geraete im Heimnetz nicht selbst ansprechen. Dieses kleine
Programm laeuft deshalb auf dem eigenen Rechner und reicht Scans an die Seite
weiter. Drei Quellen, nur Standardbibliothek:

  - Netzwerkscanner ueber mDNS + eSCL (AirScan - HP, Canon, Epson, Brother ...),
    ganz ohne Treiber, auf jedem System
  - Windows: alles, was in Windows als Scanner eingerichtet ist (WIA, ueber
    PowerShell) - z.B. USB-Scanner oder Geraete ohne AirScan
  - Linux: alles, was SANE kennt (scanimage, Paket sane-utils)

    python scan.py                          # http://127.0.0.1:7710
    python scan.py --scanner 192.168.1.50   # Scanner von Hand, ohne Suche

Schnittstelle (JSON, nur fuer die erlaubten Herkuenfte unten):
    GET  /ping      -> {"scan": true, "version": 1}
    GET  /scanners  -> {"scanners": [{id, name, adf}]}
    POST /scan      {scanner, source: Platen|Feeder, color: RGB24|Grayscale8, dpi}
                    -> {"pages": [base64-JPEG, ...]}
"""

import argparse
import base64
import glob
import json
import os
import re
import shutil
import socket
import struct
import subprocess
import tempfile
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin
from urllib.request import Request, urlopen

VERSION = 2
# Nur diese Seiten duerfen scannen - sonst koennte jede beliebige Webseite,
# die man gerade offen hat, still den Inhalt der Glasplatte abholen.
HERKUENFTE = {'https://anivia.ch', 'https://fireduck.eu', 'http://localhost:8000'}

# A4 in 1/300 Zoll - eSCL rechnet in dieser Einheit, unabhaengig von der Aufloesung
A4_BREIT, A4_HOCH = 2480, 3507

EINSTELLUNGEN = """<?xml version="1.0" encoding="UTF-8"?>
<scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
  <pwg:Version>2.6</pwg:Version>
  <pwg:ScanRegions><pwg:ScanRegion>
    <pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
    <pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset>
    <pwg:Width>{breit}</pwg:Width><pwg:Height>{hoch}</pwg:Height>
  </pwg:ScanRegion></pwg:ScanRegions>
  <pwg:InputSource>{quelle}</pwg:InputSource>
  <scan:ColorMode>{farbe}</scan:ColorMode>
  <scan:XResolution>{dpi}</scan:XResolution><scan:YResolution>{dpi}</scan:YResolution>
  <pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
  <scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
</scan:ScanSettings>"""


# --- Suche ueber mDNS -------------------------------------------------------

def _name_lesen(d, i):
    """DNS-Name ab Stelle i, mit Kompressionszeigern. -> (name, stelle danach)"""
    teile, danach = [], None
    for _ in range(64):  # Schutz gegen Zeigerschleifen
        n = d[i]
        if n == 0:
            i += 1
            break
        if n & 0xC0 == 0xC0:
            if danach is None:
                danach = i + 2
            i = ((n & 0x3F) << 8) | d[i + 1]
            continue
        teile.append(d[i + 1:i + 1 + n].decode('utf-8', 'replace'))
        i += 1 + n
    return '.'.join(teile), (danach if danach is not None else i)


def _antwort_lesen(d):
    """Port, Ressourcenpfad und Modellname aus einer mDNS-Antwort."""
    _, _, qd, an, ns, ar = struct.unpack('>HHHHHH', d[:12])
    i = 12
    for _ in range(qd):
        _, i = _name_lesen(d, i)
        i += 4
    info = {'port': 80, 'rs': 'eSCL', 'name': ''}
    for _ in range(an + ns + ar):
        name, i = _name_lesen(d, i)
        typ, _, _, laenge = struct.unpack('>HHIH', d[i:i + 10])
        i += 10
        rdata = d[i:i + laenge]
        if typ == 33:                                  # SRV
            info['port'] = struct.unpack('>H', rdata[4:6])[0]
        elif typ == 16:                                # TXT
            j = 0
            while j < len(rdata):
                n = rdata[j]
                k, _, v = rdata[j + 1:j + 1 + n].decode('utf-8', 'replace').partition('=')
                if k.lower() == 'rs':
                    info['rs'] = v.strip('/')
                elif k.lower() == 'ty':
                    info['name'] = v
                j += 1 + n
        elif typ == 12 and not info['name']:           # PTR: Instanzname
            info['name'] = _name_lesen(d, i)[0].split('._uscan')[0]
        i += laenge
    return info


def scanner_suchen(dauer=2.0):
    frage = struct.pack('>HHHHHH', 0, 0, 1, 0, 0, 0)
    for teil in '_uscan._tcp.local'.split('.'):
        frage += bytes([len(teil)]) + teil.encode()
    frage += b'\x00' + struct.pack('>HH', 12, 1)

    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
    s.settimeout(0.25)
    # Absendeport != 5353: die Scanner antworten dann direkt an uns (RFC 6762,
    # "legacy unicast") und der Windows-eigene mDNS-Dienst stoert nicht.
    s.sendto(frage, ('224.0.0.251', 5353))
    gefunden, ende = {}, time.time() + dauer
    while time.time() < ende:
        try:
            d, (ip, _) = s.recvfrom(9000)
        except socket.timeout:
            continue
        except OSError:
            break
        try:
            info = _antwort_lesen(d)
        except (struct.error, IndexError):
            continue
        gefunden[ip] = {'id': f"http://{ip}:{info['port']}/{info['rs']}",
                        'name': info['name'] or ip}
    s.close()
    return list(gefunden.values())


def basis_von(angabe):
    """IP oder URL -> eSCL-Basisadresse."""
    if angabe.startswith('http'):
        return angabe.rstrip('/')
    return f'http://{angabe}/eSCL'


def hat_einzug(basis):
    try:
        with urlopen(basis + '/ScannerCapabilities', timeout=4) as r:
            return b'Adf' in r.read()
    except (URLError, OSError):
        return False


# --- Scannen ----------------------------------------------------------------

def scannen(basis, quelle, farbe, dpi):
    xml = EINSTELLUNGEN.format(breit=A4_BREIT, hoch=A4_HOCH, quelle=quelle, farbe=farbe, dpi=dpi)
    auftrag = Request(basis + '/ScanJobs', data=xml.encode(), method='POST',
                      headers={'Content-Type': 'text/xml'})
    try:
        with urlopen(auftrag, timeout=30) as r:
            ort = r.headers.get('Location', '')
    except HTTPError as e:
        if e.code == 409:
            raise RuntimeError('Scanner is busy or the document feeder is empty.')
        if e.code == 503:
            raise RuntimeError('Scanner is busy - try again in a moment.')
        raise RuntimeError(f'Scanner refused the job (HTTP {e.code}).')
    if not ort:
        raise RuntimeError('Scanner did not start a job.')
    ort = urljoin(basis + '/', ort).rstrip('/')

    seiten, warten = [], 0
    while len(seiten) < 200:
        try:
            with urlopen(ort + '/NextDocument', timeout=180) as r:
                seiten.append(r.read())
        except HTTPError as e:
            if e.code == 404:          # keine weitere Seite - Auftrag fertig
                break
            if e.code == 503 and warten < 30:
                warten += 1
                time.sleep(1)
                continue
            raise RuntimeError(f'Scan failed (HTTP {e.code}).')
        if quelle == 'Platen':
            # Glasplatte: genau eine Seite. Auftrag schliessen, sonst meldet
            # sich das Geraet noch eine Weile als beschaeftigt.
            try:
                urlopen(Request(ort, method='DELETE'), timeout=5).close()
            except (URLError, OSError):
                pass
            break
    if not seiten:
        raise RuntimeError('No pages came back - is there paper in the feeder?')
    return seiten


# --- Windows: WIA -----------------------------------------------------------
# Python allein kommt nicht an COM; PowerShell schon, und die ist auf jedem
# Windows dabei. Werte gehen als Umgebungsvariablen hinein, nie in den
# Skripttext - so kann eine Geraete-ID nichts einschleusen.

_PS_KOPF = r"""
$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'
[Console]::OutputEncoding = [Text.Encoding]::UTF8
function Lies($props, $id) { foreach ($p in $props) { if ($p.PropertyID -eq $id) { return $p.Value } }; return $null }
function Setze($props, $id, $wert) { foreach ($p in $props) { if ($p.PropertyID -eq $id) { try { $p.Value = $wert } catch {}; return } } }
"""

WIA_LISTE = _PS_KOPF + r"""
try {
  $dm = New-Object -ComObject WIA.DeviceManager
  $liste = @()
  foreach ($i in $dm.DeviceInfos) {
    if ($i.Type -ne 1) { continue }   # 1 = Scanner. Windows' eigene AirScan-Kopie hat einen anderen Typ.
    $adf = $false
    try { $adf = ([int](Lies $i.Connect().Properties 3086) -band 1) -ne 0 } catch {}
    $liste += @{ id = $i.DeviceID; name = [string](Lies $i.Properties 7); adf = $adf }
  }
  ConvertTo-Json -InputObject @($liste) -Compress
} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }
"""

WIA_SCAN = _PS_KOPF + r"""
try {
  $dm = New-Object -ComObject WIA.DeviceManager
  $info = $null
  foreach ($i in $dm.DeviceInfos) { if ($i.DeviceID -eq $env:SCAN_ID) { $info = $i } }
  if (-not $info) { throw 'Scanner not found.' }
  $dev = $info.Connect()
  $feeder = $env:SCAN_SOURCE -eq 'Feeder'
  $dpi = [int]$env:SCAN_DPI
  # WIA 1: Quelle am Geraet (3088: 1 = Einzug, 2 = Glas)
  Setze $dev.Properties 3088 $(if ($feeder) { 1 } else { 2 })
  # WIA 2: Glas und Einzug sind eigene Unterelemente, erkennbar an der Kategorie (4125)
  $kat = if ($feeder) { '{FE131934-F84C-42AD-8DA4-6129CDDD7288}' } else { '{FB607B1F-43F3-488B-855B-FB703EC342A6}' }
  $item = $dev.Items.Item(1)
  foreach ($it in $dev.Items) { if ("$(Lies $it.Properties 4125)" -eq $kat) { $item = $it } }
  # Reihenfolge zaehlt: die Absicht (6146) setzt Aufloesung und Ausschnitt zurueck
  Setze $item.Properties 6146 $(if ($env:SCAN_COLOR -eq 'Grayscale8') { 2 } else { 1 })
  Setze $item.Properties 6147 $dpi
  Setze $item.Properties 6148 $dpi
  Setze $item.Properties 6151 ([int](8.27 * $dpi))    # A4; geht es nicht, bleibt die ganze Flaeche
  Setze $item.Properties 6152 ([int](11.69 * $dpi))
  $jpeg = '{B96B3CAE-0728-11D3-9D7B-0000F81EF32E}'
  $n = 0
  while ($n -lt 200) {
    try { $bild = $item.Transfer($jpeg) }
    catch { if ($n -gt 0) { break }; throw }     # Einzug leer = fertig
    $n++
    $ziel = Join-Path $env:SCAN_OUT "$n.jpg"
    if ($bild.FormatID -eq $jpeg) {
      $bild.SaveFile($ziel)
    } else {
      # Treiber liefert BMP. WIAs eigener Umwandlungsfilter scheitert unter
      # PowerShell an einem Cast-Fehler, System.Drawing tut es zuverlaessig.
      $roh = Join-Path $env:SCAN_OUT "$n.$($bild.FileExtension)"
      $bild.SaveFile($roh)
      Add-Type -AssemblyName System.Drawing
      $b = [System.Drawing.Image]::FromFile($roh)
      $b.Save($ziel, [System.Drawing.Imaging.ImageFormat]::Jpeg)
      $b.Dispose()
      Remove-Item $roh
    }
    if (-not $feeder) { break }
  }
} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }
"""


def _powershell(skript, umgebung=None, frist=600):
    befehl = ['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
              '-EncodedCommand', base64.b64encode(skript.encode('utf-16-le')).decode()]
    r = subprocess.run(befehl, capture_output=True, timeout=frist,
                       env={**os.environ, **(umgebung or {})},
                       creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
    if r.returncode != 0:
        try:
            text = r.stderr.decode('utf-8')
        except UnicodeDecodeError:              # Fehlerkanal kommt in der OEM-Codepage
            text = r.stderr.decode('oem', 'replace')
        text = text.strip() or 'PowerShell failed'
        if '80210003' in text or 'paper' in text.lower():
            text = 'The document feeder is empty.'
        raise RuntimeError(text.splitlines()[-1])
    return r.stdout.decode('utf-8', 'replace')


def wia_liste():
    if os.name != 'nt':
        return []
    try:
        geraete = json.loads(_powershell(WIA_LISTE, frist=60) or '[]')
    except (RuntimeError, ValueError, OSError, subprocess.TimeoutExpired):
        return []
    # ponytail: WIA nur fuer die Glasplatte - ueber die Skriptschnittstelle zieht
    # ein WIA-2-Treiber (getestet: HP ENVY Inspire) alle Blaetter ein, liefert
    # aber nur das erste Bild. Netzwerkgeraete laufen ohnehin ueber eSCL oben.
    return [{'id': 'wia:' + g['id'], 'name': (g['name'] or 'Scanner') + ' (Windows)', 'adf': False}
            for g in geraete]


def wia_scannen(geraet, quelle, farbe, dpi):
    with tempfile.TemporaryDirectory() as ablage:
        _powershell(WIA_SCAN, {'SCAN_ID': geraet, 'SCAN_SOURCE': quelle, 'SCAN_COLOR': farbe,
                               'SCAN_DPI': str(dpi), 'SCAN_OUT': ablage})
        return _seiten_lesen(ablage)


# --- Linux: SANE ------------------------------------------------------------
# scanimage aus sane-utils. Werte gehen als Argumentliste ohne Shell hinein,
# das Geraet als --device-name=..., damit es nie als Option gelesen wird.

def _ist_einzug(quelle):
    return re.search(r'adf|feeder|document', quelle, re.I) is not None


def _sane_werte(optionen, name):
    """Moegliche Werte einer Option aus `scanimage -A`, z.B. --source Flatbed|ADF [Flatbed]"""
    m = re.search(r'--' + name + r' ([^\[\n]*?)\s*\[', optionen)
    return [w.strip() for w in m.group(1).split('|')] if m else []


def _sane_optionen(geraet):
    return subprocess.run(['scanimage', f'--device-name={geraet}', '-A'],
                          capture_output=True, text=True, timeout=60).stdout


def sane_liste():
    if not shutil.which('scanimage'):
        return []
    try:
        r = subprocess.run(['scanimage', '-f', '%d\t%v %m%n'], capture_output=True, text=True, timeout=60)
    except (OSError, subprocess.TimeoutExpired):
        return []
    liste = []
    for zeile in r.stdout.splitlines():
        geraet, _, name = zeile.partition('\t')
        # AirScan-Geraete findet die eigene Suche schon - sonst stuenden sie doppelt da
        if not geraet or geraet.startswith(('escl:', 'airscan:')):
            continue
        quellen = _sane_werte(_sane_optionen(geraet), 'source')
        liste.append({'id': 'sane:' + geraet, 'name': name.strip() + ' (SANE)',
                      'adf': any(_ist_einzug(q) for q in quellen)})
    return liste


def sane_scannen(geraet, quelle, farbe, dpi):
    optionen = _sane_optionen(geraet)
    befehl = ['scanimage', f'--device-name={geraet}', '--format=jpeg', f'--resolution={dpi}']
    ziel = 'gray' if farbe == 'Grayscale8' else 'color'
    modus = next((m for m in _sane_werte(optionen, 'mode') if m.lower().startswith(ziel)), None)
    if modus:
        befehl.append(f'--mode={modus}')
    feeder = quelle == 'Feeder'
    q = next((q for q in _sane_werte(optionen, 'source')
              if _ist_einzug(q) == feeder and 'duplex' not in q.lower()), None)
    if q:
        befehl.append(f'--source={q}')
    if re.search(r'^\s*-x ', optionen, re.M) and re.search(r'^\s*-y ', optionen, re.M):
        befehl += ['-x', '210', '-y', '297']          # A4 in mm
    with tempfile.TemporaryDirectory() as ablage:
        befehl.append('--batch=' + os.path.join(ablage, '%d.jpg'))
        if not feeder:
            befehl.append('--batch-count=1')
        r = subprocess.run(befehl, capture_output=True, text=True, timeout=600)
        seiten = _seiten_lesen(ablage)
        if not seiten:
            # Einzug leer endet ebenfalls mit Fehlercode - nur ohne Seiten ist es einer
            fehler = r.stderr.strip().splitlines()
            raise RuntimeError(fehler[-1] if fehler else 'No pages were scanned.')
        return seiten


def _seiten_lesen(ablage):
    dateien = sorted(glob.glob(os.path.join(ablage, '*.jpg')),
                     key=lambda p: int(os.path.splitext(os.path.basename(p))[0]))
    seiten = []
    for pfad in dateien:
        with open(pfad, 'rb') as f:
            seiten.append(f.read())
    return seiten


def alle_scanner(vorgabe):
    liste = ([{'id': basis_von(vorgabe), 'name': vorgabe}] if vorgabe else scanner_suchen())
    for s in liste:
        s['adf'] = hat_einzug(s['id'])
    return liste + wia_liste() + sane_liste()


# --- HTTP -------------------------------------------------------------------

class Handler(BaseHTTPRequestHandler):
    vorgabe = ''

    def _kopf(self, code, laenge=0):
        self.send_response(code)
        herkunft = self.headers.get('Origin', '')
        if herkunft in HERKUENFTE:
            self.send_header('Access-Control-Allow-Origin', herkunft)
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
            self.send_header('Access-Control-Allow-Headers', 'Content-Type')
            self.send_header('Access-Control-Allow-Private-Network', 'true')
            self.send_header('Vary', 'Origin')
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(laenge))
        self.end_headers()

    def _json(self, code, daten):
        roh = json.dumps(daten).encode()
        self._kopf(code, len(roh))
        self.wfile.write(roh)

    def _erlaubt(self):
        # Ohne Origin (curl, Adresszeile) ist es der Benutzer selbst
        herkunft = self.headers.get('Origin')
        if herkunft is None or herkunft in HERKUENFTE:
            return True
        self._json(403, {'error': 'origin not allowed'})
        return False

    def do_OPTIONS(self):
        self._kopf(204)

    def do_GET(self):
        if not self._erlaubt():
            return
        if self.path == '/ping':
            return self._json(200, {'scan': True, 'version': VERSION})
        if self.path == '/scanners':
            return self._json(200, {'scanners': alle_scanner(self.vorgabe)})
        self._json(404, {'error': 'not found'})

    def do_POST(self):
        if not self._erlaubt():
            return
        if self.path != '/scan':
            return self._json(404, {'error': 'not found'})
        try:
            a = json.loads(self.rfile.read(int(self.headers.get('Content-Length') or 0)) or b'{}')
            geraet = str(a.get('scanner', ''))
            quelle = 'Feeder' if a.get('source') == 'Feeder' else 'Platen'
            farbe = 'Grayscale8' if a.get('color') == 'Grayscale8' else 'RGB24'
            dpi = int(a.get('dpi', 300))
            if dpi not in (150, 200, 300, 600):
                dpi = 300
            if geraet.startswith('wia:') and os.name == 'nt':
                seiten = wia_scannen(geraet[4:], quelle, farbe, dpi)
            elif geraet.startswith('sane:') and shutil.which('scanimage'):
                seiten = sane_scannen(geraet[5:], quelle, farbe, dpi)
            # Sonst nur echte eSCL-Adressen, keine beliebigen URLs
            elif re.fullmatch(r'http://[\w.\-:\[\]]+/[\w/]*', geraet):
                seiten = scannen(geraet.rstrip('/'), quelle, farbe, dpi)
            else:
                return self._json(400, {'error': 'bad scanner'})
            self._json(200, {'pages': [base64.b64encode(s).decode() for s in seiten]})
        except RuntimeError as e:
            self._json(502, {'error': str(e)})
        except subprocess.TimeoutExpired:
            self._json(504, {'error': 'The scanner took too long.'})
        except (URLError, OSError) as e:
            self._json(502, {'error': f'Scanner not reachable: {e}'})

    def log_message(self, fmt, *args):
        print(time.strftime('%H:%M:%S'), fmt % args)


def main():
    p = argparse.ArgumentParser(description='Scan helper for the PDF Editor')
    p.add_argument('--port', type=int, default=7710)
    p.add_argument('--scanner', default='', help='IP or eSCL URL, skips the network search')
    a = p.parse_args()
    Handler.vorgabe = a.scanner
    srv = ThreadingHTTPServer(('127.0.0.1', a.port), Handler)
    print(f'Scan helper running on http://127.0.0.1:{a.port} - leave this window open.')
    gefunden = alle_scanner(a.scanner)
    print('Scanners found:', ', '.join(s['name'] for s in gefunden) or 'none (use --scanner IP)')
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == '__main__':
    main()
