"""GE-Enforce manifest-store models.

The manifest that GE-Enforce runs on each PC becomes shopdb data. A
`ManifestScope` is one imaging PC type (a `gea-shopfloor-*` scope, the `common`
fleet-wide scope, or the single flat `preinstall` scope). Each scope owns an
ordered list of `ManifestEntry` rows - one per Applications[] entry in the
on-share manifest.json - plus child rows for the multi-value targeting filters
and the nested InUseCheck.

Design choices (see docs/proposals/ge-enforce-plugin.md):
- ONE wide entries table with an `entrytype` discriminator and nullable
  per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries,
  so sparse columns are free and every field stays queryable + plain-SQL
  readable by IT.
- Array order IS execution order, so `sortorder` is a real column and the
  contract; config-restore entries sit after their installer on purpose.
- `computertypeid` / `measuringtooltypeid` are SOFT references (plain Integer,
  no DB foreign key) to other plugins' tables, which may not be installed.
- Published manifests are immutable snapshots that freeze the rendered JSON
  document (`ManifestPublishedVersion`); the draft (`ManifestEntry`) is never
  served to a client.
"""

from shopdb.api import db, BaseModel


# Allowed enumerations, validated in the API/service layer (stored as strings
# for portability, matching the rest of the codebase).
PHASES = ('runtime', 'preinstall')
ENTRY_TYPES = ('MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry')
REG_TYPES = ('String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary')
PAYLOAD_SOURCES = ('smb', 'http', 'inline')
DETECTION_METHODS = ('Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
                     'ValueMatches', 'pnputil', 'Always')
APPLY_MODES = ('Nightly', 'Immediate', 'ImmediateReboot')
INUSE_BEHAVIORS = ('Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot')


class ManifestScope(BaseModel):
    """One imaging PC type / manifest scope (the working/draft head)."""
    __tablename__ = 'manifestscopes'

    scopeid = db.Column(db.Integer, primary_key=True)
    # 'common', 'gea-shopfloor-cmm', 'preinstall', ...
    scopename = db.Column(db.String(64), nullable=False)
    # 'runtime' (per-pctype manifest.json) or 'preinstall' (one flat manifest)
    phase = db.Column(db.String(16), nullable=False, default='runtime')
    # Soft refs (no FK): the plugins that own these tables are optional.
    computertypeid = db.Column(db.Integer, nullable=True)
    measuringtooltypeid = db.Column(db.Integer, nullable=True)
    manifestversion = db.Column(db.String(16), nullable=False, default='1.0')
    # Lowest GE-Enforce client library that may enforce this scope, e.g. '2.6'.
    #
    # SEPARATE FROM manifestversion, which is the manifest's own 'Version' field
    # and is whatever its author wrote - seed_display_scope picks '2.0'. The
    # publish gate used manifestversion as the library floor, so for every
    # code-authored scope it compared the fleet against a number that says
    # nothing about library features, and passed. NULL means "derive it", see
    # service.required_libversion.
    minlibversion = db.Column(db.String(16), nullable=True)
    description = db.Column(db.String(255), nullable=True)
    # The manifest-level '_comment' (documentation), preserved verbatim so
    # export-to-share round-trips it. Excluded from behavioral parity.
    topcomment = db.Column(db.Text, nullable=True)
    iscommon = db.Column(db.Boolean, nullable=False, default=False)
    # Preinstall manifests carry a top-level 'Site' field.
    site = db.Column(db.String(100), nullable=True)

    entries = db.relationship(
        'ManifestEntry', back_populates='scope',
        cascade='all, delete-orphan', order_by='ManifestEntry.sortorder',
        lazy='selectin')
    publishedversions = db.relationship(
        'ManifestPublishedVersion', back_populates='scope',
        cascade='all, delete-orphan',
        order_by='ManifestPublishedVersion.versionnumber', lazy='dynamic')

    __table_args__ = (
        db.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
    )

    def __repr__(self):
        return f"<ManifestScope {self.scopename}/{self.phase}>"


class ManifestEntry(BaseModel):
    """One Applications[] entry (draft). Wide table, entrytype discriminator."""
    __tablename__ = 'manifestentries'

    entryid = db.Column(db.Integer, primary_key=True)
    scopeid = db.Column(
        db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
        nullable=False, index=True)
    sortorder = db.Column(db.Integer, nullable=False, default=0)
    name = db.Column(db.String(128), nullable=False)
    entrytype = db.Column(db.String(16), nullable=False)
    comment = db.Column(db.Text, nullable=True)          # manifest '_comment'

    # Type-specific payload references (nullable; validated per entrytype).
    installer = db.Column(db.String(255), nullable=True)
    installargs = db.Column(db.Text, nullable=True)      # can run long
    scriptpath = db.Column(db.String(255), nullable=True)   # 'Script'
    scriptargs = db.Column(db.String(255), nullable=True)   # 'Args'
    sourcepath = db.Column(db.String(255), nullable=True)   # 'Source'
    destination = db.Column(db.String(255), nullable=True)
    regpath = db.Column(db.String(255), nullable=True)
    regname = db.Column(db.String(128), nullable=True)
    # Raw JSON literal preserved verbatim (1 vs "1"); DWord typing depends on it.
    regvalue = db.Column(db.Text, nullable=True)
    regtype = db.Column(db.String(16), nullable=True)

    # Payload transport + integrity (integrity hash is SEPARATE from detection).
    payloadsource = db.Column(db.String(8), nullable=False, default='smb')
    payloadref = db.Column(db.String(512), nullable=True)
    payloadsha256 = db.Column(db.String(64), nullable=True)

    # Detection (decides whether the action fires / self-heals).
    detectionmethod = db.Column(db.String(16), nullable=True)
    detectionpath = db.Column(db.String(255), nullable=True)
    detectionname = db.Column(db.String(128), nullable=True)
    detectionvalue = db.Column(db.String(255), nullable=True)
    detectionpattern = db.Column(db.String(255), nullable=True)

    # Gates + control.
    cmmversion = db.Column(db.String(16), nullable=True)    # '_CmmVersion'
    logfile = db.Column(db.String(255), nullable=True)
    waittimeoutsec = db.Column(db.Integer, nullable=True)
    applymode = db.Column(db.String(16), nullable=True)     # inert in engine today
    updatewindow = db.Column(db.String(11), nullable=True)  # 'HH:MM-HH:MM', inert

    # Optional CURATED link to a core Applications catalog row (soft ref, no FK
    # to keep the plugin decoupled). shopdb metadata only - NOT part of the
    # manifest JSON the engine reads, so it never affects enforcement or parity.
    appid = db.Column(db.Integer, nullable=True)

    # Preinstall-only flags.
    preenrollment = db.Column(db.Boolean, nullable=False, default=False)
    killafterdetection = db.Column(db.Boolean, nullable=False, default=False)
    pctypesstrict = db.Column(db.Boolean, nullable=False, default=False)

    scope = db.relationship('ManifestScope', back_populates='entries')
    pctypes = db.relationship(
        'ManifestEntryPcType', back_populates='entry',
        cascade='all, delete-orphan', order_by='ManifestEntryPcType.sortorder',
        lazy='selectin')
    hostnames = db.relationship(
        'ManifestEntryHostname', back_populates='entry',
        cascade='all, delete-orphan', order_by='ManifestEntryHostname.sortorder',
        lazy='selectin')
    machinenumbers = db.relationship(
        'ManifestEntryMachineNumber', back_populates='entry',
        cascade='all, delete-orphan',
        order_by='ManifestEntryMachineNumber.sortorder', lazy='selectin')
    inusecheck = db.relationship(
        'ManifestInUseCheck', back_populates='entry', uselist=False,
        cascade='all, delete-orphan', lazy='selectin')

    __table_args__ = (
        db.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
        db.Index('idx_entry_scope_order', 'scopeid', 'sortorder'),
    )

    def __repr__(self):
        return f"<ManifestEntry {self.name} ({self.entrytype})>"


class ManifestEntryPcType(db.Model):
    """One value of an entry's PCTypes filter (verbatim, incl. '*' and aliases)."""
    __tablename__ = 'manifestentrypctypes'

    entrypctypeid = db.Column(db.Integer, primary_key=True)
    entryid = db.Column(
        db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
        nullable=False, index=True)
    sortorder = db.Column(db.Integer, nullable=False, default=0)
    pctypevalue = db.Column(db.String(64), nullable=False)

    entry = db.relationship('ManifestEntry', back_populates='pctypes')


class ManifestEntryHostname(db.Model):
    """One value of an entry's TargetHostnames filter (wildcards kept verbatim)."""
    __tablename__ = 'manifestentryhostnames'

    entryhostnameid = db.Column(db.Integer, primary_key=True)
    entryid = db.Column(
        db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
        nullable=False, index=True)
    sortorder = db.Column(db.Integer, nullable=False, default=0)
    hostnamepattern = db.Column(db.String(64), nullable=False)

    entry = db.relationship('ManifestEntry', back_populates='hostnames')


class ManifestEntryMachineNumber(db.Model):
    """One value of an entry's TargetMachineNumbers filter."""
    __tablename__ = 'manifestentrymachinenumbers'

    entrymachinenumberid = db.Column(db.Integer, primary_key=True)
    entryid = db.Column(
        db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
        nullable=False, index=True)
    sortorder = db.Column(db.Integer, nullable=False, default=0)
    machinenumber = db.Column(db.String(16), nullable=False)

    entry = db.relationship('ManifestEntry', back_populates='machinenumbers')


class ManifestInUseCheck(db.Model):
    """The InUseCheck object on an entry (0..1), with its Processes[] children."""
    __tablename__ = 'manifestinusechecks'

    inusecheckid = db.Column(db.Integer, primary_key=True)
    entryid = db.Column(
        db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
        nullable=False, unique=True)
    behavior = db.Column(db.String(20), nullable=False)

    entry = db.relationship('ManifestEntry', back_populates='inusecheck')
    processes = db.relationship(
        'ManifestInUseCheckProcess', back_populates='inusecheck',
        cascade='all, delete-orphan',
        order_by='ManifestInUseCheckProcess.sortorder', lazy='selectin')


class ManifestInUseCheckProcess(db.Model):
    """One process in an InUseCheck's Processes[] list."""
    __tablename__ = 'manifestinusecheckprocesses'

    inusecheckprocessid = db.Column(db.Integer, primary_key=True)
    inusecheckid = db.Column(
        db.Integer,
        db.ForeignKey('manifestinusechecks.inusecheckid', ondelete='CASCADE'),
        nullable=False, index=True)
    sortorder = db.Column(db.Integer, nullable=False, default=0)
    processname = db.Column(db.String(64), nullable=False)
    exepath = db.Column(db.String(255), nullable=True)
    # Null = engine default (10); do not bake the default into the row.
    gracefulclosetimeoutsec = db.Column(db.Integer, nullable=True)

    inusecheck = db.relationship('ManifestInUseCheck', back_populates='processes')


class ManifestPublishedVersion(db.Model):
    """Immutable published snapshot: the frozen rendered JSON document.

    The client is always served the `iscurrent` snapshot for a scope, never the
    live draft. Rollback flips `iscurrent`. Freezing the text (not row-mirroring)
    makes immutability structural and rollback a one-flag change.
    """
    __tablename__ = 'manifestpublishedversions'

    publishedversionid = db.Column(db.Integer, primary_key=True)
    scopeid = db.Column(
        db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
        nullable=False, index=True)
    versionnumber = db.Column(db.Integer, nullable=False)
    # MEDIUMTEXT on MySQL (a full scope with comments can exceed 64 KB TEXT).
    manifestjson = db.Column(db.Text(length=16777215), nullable=False)
    publishedat = db.Column(db.DateTime, nullable=False)
    publishedby = db.Column(db.Integer, nullable=True)     # soft ref to users
    iscurrent = db.Column(db.Boolean, nullable=False, default=False)
    notes = db.Column(db.String(255), nullable=True)

    scope = db.relationship('ManifestScope', back_populates='publishedversions')

    __table_args__ = (
        db.UniqueConstraint('scopeid', 'versionnumber',
                            name='uq_published_scope_version'),
    )


class ManifestPayload(db.Model):
    """Inline payload bytes for payloadsource='inline' (small scripts/configs)."""
    __tablename__ = 'manifestpayloads'

    payloadid = db.Column(db.Integer, primary_key=True)
    entryid = db.Column(
        db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
        nullable=False, index=True)
    filename = db.Column(db.String(255), nullable=False)
    contenttype = db.Column(db.String(128), nullable=True)
    payloadbytes = db.Column(db.LargeBinary(length=16777215), nullable=False)
    payloadsha256 = db.Column(db.String(64), nullable=False)
    uploadedat = db.Column(db.DateTime, nullable=False)


class ManifestEnforcementReport(db.Model):
    """One enforcement cycle reported by a PC (observed state).

    Each cycle the client POSTs its result for a scope: the published version it
    actually applied (so shopdb knows whether the PC RECEIVED the latest update),
    the enforcer version, and the installed/skipped/failed/filtered counts. The
    latest report per (hostname, scopename, phase) carries `iscurrent`; older
    ones are history. Pairs desired state (the manifest) with observed state.
    """
    __tablename__ = 'manifestenforcementreports'

    reportid = db.Column(db.Integer, primary_key=True)
    hostname = db.Column(db.String(100), nullable=False, index=True)
    scopename = db.Column(db.String(64), nullable=False)
    phase = db.Column(db.String(16), nullable=False, default='runtime')
    # Published version the client actually ran; compare to the scope's current
    # published version to see whether this PC received the latest manifest.
    appliedversion = db.Column(db.Integer, nullable=True)
    enforcerversion = db.Column(db.String(20), nullable=True)
    # What the PC says it IS within its scope (a display's Dashboard / Lobby /
    # 3DPrintRoom). Reported by the device, which reads it from its own
    # enrollment file - not inferred from a mapping table that may have no row
    # for this host.
    subtype = db.Column(db.String(50), nullable=True)
    installedcount = db.Column(db.Integer, nullable=False, default=0)
    skippedcount = db.Column(db.Integer, nullable=False, default=0)
    failedcount = db.Column(db.Integer, nullable=False, default=0)
    filteredcount = db.Column(db.Integer, nullable=False, default=0)
    # 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure).
    status = db.Column(db.String(16), nullable=False, default='ok')
    lastcheckin = db.Column(db.DateTime, nullable=True)   # PC-reported time
    receivedat = db.Column(db.DateTime, nullable=False)   # server time
    iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True)

    results = db.relationship(
        'ManifestEnforcementResult', back_populates='report',
        cascade='all, delete-orphan', lazy='selectin')

    __table_args__ = (
        db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'),
    )


class ManifestEnforcementResult(db.Model):
    """One entry's outcome within an enforcement cycle (self-heal detail)."""
    __tablename__ = 'manifestenforcementresults'

    resultid = db.Column(db.Integer, primary_key=True)
    reportid = db.Column(
        db.Integer,
        db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'),
        nullable=False, index=True)
    entryname = db.Column(db.String(128), nullable=False)
    # 'installed' (action fired - a self-heal when it should already be present),
    # 'skipped' (detected present), 'failed', 'filtered'.
    action = db.Column(db.String(16), nullable=False)
    # True when this install was a drift correction (self-heal), not a first
    # install. Client-supplied; defaults to whether the action installed.
    selfhealed = db.Column(db.Boolean, nullable=False, default=False)
    exitcode = db.Column(db.Integer, nullable=True)
    message = db.Column(db.Text, nullable=True)   # warning / error text

    report = db.relationship('ManifestEnforcementReport', back_populates='results')


class PcTypeAlias(db.Model):
    """Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1).

    Rows sharing an `aliasgroup` are one alias set. Server-side resolve/validate
    only; the engine lib stays the single source of truth (never inverted).
    """
    __tablename__ = 'pctypealiases'

    aliasid = db.Column(db.Integer, primary_key=True)
    aliasgroup = db.Column(db.Integer, nullable=False, index=True)
    aliasname = db.Column(db.String(64), nullable=False)

    __table_args__ = (
        db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'),
    )


class ManifestBlob(db.Model):
    """Content-addressed payload blob for http-delivered installers.

    Bytes live on disk at <instance>/geenforce/payloads/<sha256> (deduped by
    content, so a payload shared by many entries is stored once). Manifest
    entries reference a blob by payloadsha256; the client fetches it from
    GET /api/geenforce/payload/<sha256> over HTTPS and verifies the hash. This
    is how big installers reach share-less (Intune/local-account) PCs.
    """
    __tablename__ = 'manifestblobs'

    sha256 = db.Column(db.String(64), primary_key=True)
    filename = db.Column(db.String(255), nullable=False)
    contenttype = db.Column(db.String(128), nullable=True)
    sizebytes = db.Column(db.BigInteger, nullable=False)
    createdat = db.Column(db.DateTime, nullable=False)
