"""Backup kind registry.

A "kind" is one category of configuration backup (NTLARS/DNC registry, part
marker, UDC, CMM ...). Each kind declares three things the rest of the plugin
needs and nothing else:

  where its bytes live      'shopdb' (parsed into payloadjson) or 'share'
                            (opaque vendor file left on the SFLD share)
  how to resolve an asset   the collector reports an identifier; the kind turns
                            it into the assetid the revision belongs to
  how to render downloads   one or more output formats

Adding a kind is a class plus one REGISTRY entry. Nothing else in the plugin
knows kind names.
"""

import hashlib
import json

from . import ntlars as ntlarscodec

# Root of the opaque-backup tree on the site's file share. Connected PCs write
# here directly (they need share creds - a SYSTEM process hitting a UNC path
# without them gets an access-denied that Test-Path reports as "not found").
#
# EMPTY BY DESIGN (ADR-015). This used to ship one site's own file server
# as the default, which put one site's internal topology in a bundled plugin and
# in the public mirror, and silently pointed a second site at a server it cannot
# reach. The live value is the backups_shareroot setting; unset, the share kinds
# have no conventional location to offer and say so rather than composing a path
# from somebody else's hostname.
DEFAULTSHAREROOT = ''


def canonicalhash(projection):
    """sha256 over the SEMANTIC content only. Dedup key for 'shopdb' kinds.

    Deliberately hashes just schema + keys, excluding sourcedialect. The same
    settings exported through NTLARS's Save... button and through a scripted
    reg export parse to identical keys but differing sourcedialect; hashing the
    whole projection would record that as a change and produce a spurious
    revision every time the collection route changed. Storage is
    dialect-neutral, so the dedup key has to be too.
    """
    subset = {
        'schema': projection.get('schema'),
        'keys': projection.get('keys'),
    }
    blob = json.dumps(subset, sort_keys=True, separators=(',', ':'))
    return hashlib.sha256(blob.encode('utf-8')).hexdigest()


def byteshash(raw):
    """sha256 over raw bytes. Dedup key for 'share' kinds."""
    return hashlib.sha256(raw).hexdigest()


def markerforsource(sourcehostname):
    """Asset id of the part marker the reporting PC drives, or None.

    A part-marker PC's config describes ITS marker, not the operation the
    marker serves. Several markers can serve one operation - 0613, 0615 and
    WJPRT each do - so filing by machine number put several devices' configs in
    one history where they overwrote each other. The computers collector gives
    each marker PC a marker asset (`collector:partmarker`), and that is what a
    backup from that PC belongs to.

    Falls through to None, and so to the machine number, whenever anything is
    missing: no hostname, no computers or machines plugin on a lean build
    (ADR-014), or a marker PC that has not reported to the computers collector
    yet. Filing under the operation is the old behaviour and still better than
    rejecting the backup.
    """
    from shopdb.api import db, AssetRelationship, RelationshipType

    hostname = (sourcehostname or '').strip()
    if not hostname:
        return None
    try:
        from plugins.computers.models import Computer
        from plugins.computers.plugin import PARTMARKER_LINK_ORIGIN
    except ImportError:
        return None

    computer = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
    if computer is None or not computer.assetid:
        return None

    controls = RelationshipType.query.filter_by(
        relationshiptype='controls').first()
    if controls is None:
        return None

    link = (db.session.query(AssetRelationship)
            .filter(AssetRelationship.sourceassetid == computer.assetid,
                    AssetRelationship.relationshiptypeid ==
                    controls.relationshiptypeid,
                    AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
                    AssetRelationship.isactive.is_(True))
            .first())
    return link.targetassetid if link else None


class BackupKind:
    """Base class. Subclasses override what applies to them."""

    key = None
    displayname = None
    storagebackend = 'shopdb'
    assettypes = ['*']
    # Text shown when the kind has no revisions for an asset. None means the
    # panel HIDES entirely instead (the generic list renderer shows a panel
    # only when it has rows or declares empty text). Default to hiding: a kind
    # applies to an asset TYPE, but whether a given asset ever has this kind of
    # backup is a property of the individual machine. A part marker panel on
    # every one of 144 machines is noise, not information.
    emptytext = None

    def parse(self, raw):
        """Opaque kinds return None; parseable kinds return the projection."""
        return None

    def formats(self):
        """Downloadable formats: [{'id','label','ext','mimetype'}]."""
        return []

    def render(self, projection, formatid, comments=None):
        """Return (bytes, extension, mimetype) for a 'shopdb' kind."""
        raise NotImplementedError

    def resolveassetid(self, payload):
        """Map a collector payload to the assetid the backup belongs to."""
        raise NotImplementedError

    def infopanel(self):
        """Optional 'at a glance' card built from this kind's LATEST revision.

        Returns an ADR-010 panel dict, or None when the kind has no summary
        worth surfacing. Declared by the kind rather than hardcoded in the
        plugin so a successor technology (NTLARS/DNC is expected to give way to
        Shopfloor Connect) ships its own card by adding a class, without
        touching the plugin or the panel wiring.
        """
        return None

    def buildinfo(self, projection, assetid, **options):
        """Build this kind's info-card payload. Only called when infopanel()."""
        return {'fields': [], 'sectioncount': 0}

    def sharedir(self, machinetype, identifier, shareroot=None):
        """UNC directory a 'share' kind's files are expected under.

        Advisory only - the authoritative path is the sharepath the collector
        reports, since the PC is what actually wrote the file. This builds the
        conventional location for display and for validating a reported path.

        Returns '' when no share root is configured. A path built on an empty
        root is not a lesser answer, it is a wrong one - it would render as a
        relative path and validate a reported path against nothing.
        """
        root = shareroot or DEFAULTSHAREROOT
        if not root:
            return ''
        return '{}\\{}\\{}\\{}'.format(
            root, machinetype or 'unknown', identifier or 'unknown', self.key)


class NtlarsKind(BackupKind):
    """NTLARS / DNC registry settings.

    Collected from the CONTROLLING PC's registry but belongs to the MACHINE:
    the settings describe how to talk to that machine's controller, so they
    follow the machine when a PC is swapped. resolveassetid therefore keys on
    the reported machine number, not the hostname.
    """

    key = 'ntlars'
    displayname = 'NTLARS / DNC Settings'
    storagebackend = 'shopdb'
    assettypes = ['machine']

    @staticmethod
    def embeddedmachineno(projection):
        """The MachineNo NTLARS itself is configured with (General tab)."""
        for key in (projection or {}).get('keys', []):
            if (key.get('path') or '').lower() == 'general':
                entry = (key.get('values') or {}).get('MachineNo') or {}
                return str(entry.get('data') or '').strip()
        return ''

    def parse(self, raw):
        """Parse, then refuse to record an unconfigured NTLARS install.

        A freshly imaged PC opens NTLARS with a blank General tab (no MachineNo,
        CNC, Host or Interface type) until a tech restores the config. Storing
        that would make an empty config the newest revision at exactly the
        moment someone needs the last good one - so a blank MachineNo is
        rejected as "nothing worth backing up" rather than accepted as a change.
        Two of the 320 known-good backups on the share already have this shape.
        """
        projection = ntlarscodec.parse(raw)
        if not self.embeddedmachineno(projection):
            raise ValueError(
                'NTLARS General\\MachineNo is empty - unconfigured install, '
                'refusing to record it as a revision')
        return projection

    def formats(self):
        return [
            {
                'id': 'ntlars',
                'label': 'NTLARS Load... (.reg)',
                'ext': '.reg',
                'mimetype': 'application/octet-stream',
                'hint': 'Restore with the Load... button in the NTLARS settings dialog.',
            },
            {
                'id': 'wow6432node',
                'label': 'Direct reg import (.reg, WOW6432Node)',
                'ext': '.reg',
                'mimetype': 'application/octet-stream',
                'hint': 'Use when importing outside NTLARS on a 64-bit machine.',
            },
        ]

    def render(self, projection, formatid, comments=None):
        if formatid not in ('ntlars', 'wow6432node'):
            raise ValueError('unknown format for ntlars kind: {}'.format(formatid))
        raw = ntlarscodec.render(projection, dialect=formatid, comments=comments)
        return raw, '.reg', 'application/octet-stream'

    def infopanel(self):
        return {
            'id': 'backups-dncinfo',
            'title': 'DNC Info',
            'assettypes': ['machine'],
            'endpoint': '/api/backups/asset/{assetid}/info?kind=ntlars',
            # tabs, not keyvalue: General / eFocas / Serial / NTSHR / MARK are
            # one card the tech switches between, rather than a 30-row wall.
            # No 'empty' key - the renderer hides a panel with no sections, so
            # a machine with no NTLARS revision has no DNC Info card at all.
            'render': 'tabs',
            # Above the history panels: this answers the question a tech
            # arrives with, while history is for the rarer restore case.
            'position': 38,
        }

    def buildinfo(self, projection, assetid, **options):
        from . import dncinfo
        return dncinfo.build(projection, assetid,
                             partmarkertypes=options.get('partmarkertypes'))

    def resolveassetid(self, payload):
        from shopdb.api import db, Asset

        marker = markerforsource(payload.get('sourcehostname'))
        if marker is not None:
            return marker, None

        machinenumber = (payload.get('machinenumber') or '').strip()
        if not machinenumber:
            return None, 'no machinenumber in payload'
        asset = db.session.query(Asset).filter(
            Asset.assetnumber == machinenumber).first()
        if asset is None:
            return None, 'no asset with assetnumber {!r}'.format(machinenumber)
        return asset.assetid, None


class PartMarkerKind(BackupKind):
    """Telesis part marker configuration.

    Opaque vendor filetype with no useful JSON representation, so ShopDB stores
    metadata and a UNC pointer while the file itself stays on the share. The
    original filename and extension are preserved because the vendor tool
    rejects a renamed file.
    """

    key = 'partmarker'
    displayname = 'Part Marker Configuration'
    storagebackend = 'share'
    # NO asset panel. The marker's DNC settings are a tab on the single DNC Info
    # card, and a separate card that is empty on 145 of 147 machines earns
    # nobody anything. The kind is still fully live - it stores, dedupes and
    # serves revisions, which are listed on the backup history page.
    assettypes = []

    def resolveassetid(self, payload):
        from shopdb.api import db, Asset

        marker = markerforsource(payload.get('sourcehostname'))
        if marker is not None:
            return marker, None

        identifier = (payload.get('machinenumber')
                      or payload.get('assetnumber') or '').strip()
        if not identifier:
            return None, 'no machinenumber/assetnumber in payload'
        asset = db.session.query(Asset).filter(
            Asset.assetnumber == identifier).first()
        if asset is None:
            return None, 'no asset with assetnumber {!r}'.format(identifier)
        return asset.assetid, None


REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}


def getkind(key):
    return REGISTRY.get((key or '').strip().lower())
