@prefix gg:   <https://grindograph.darkcoff.ee/vocab#> .
@prefix app:  <https://darkcoff.ee/grindhog/app#> .
@prefix sh:   <http://www.w3.org/ns/shacl#> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix prov: <http://www.w3.org/ns/prov#> .
@prefix dct:  <http://purl.org/dc/terms/> .

# ============================================================================
# STRUCTURAL SHAPES
#
# These constraints protect interpretability and provenance. They deliberately do NOT
# require a scan to identify a roaster, coffee name, product, process, origin, etc.
# Unresolved and difficult scans are valid contributions.
#
# Domain-specific coffee shapes can remain advisory: if real package evidence violates
# the current coffee schema, retain the evidence and raise a gg:SchemaNote rather than
# discarding the scan.
# ============================================================================

gg:ScanCaseShape
    a sh:NodeShape ;
    sh:targetClass gg:ScanCase ;
    rdfs:label "Scan case — structural gate" ;

    sh:property [
        sh:path gg:hasImage ;
        sh:class gg:Image ;
        sh:minCount 1 ;
        sh:message "A scan case must contain at least one image." ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A scan case must be attributed to exactly one contributor IRI." ;
    ] ;

    sh:property [
        sh:path dct:license ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A scan case must carry the license under which its contribution can be used." ;
    ] ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A scan case must have exactly one creation timestamp." ;
    ] ;

    sh:property [
        sh:path gg:currentGroundTruth ;
        sh:class gg:GroundTruth ;
        sh:maxCount 1 ;
        sh:message "A scan case may point to at most one current ground-truth snapshot." ;
    ] .


gg:ImageShape
    a sh:NodeShape ;
    sh:targetClass gg:Image ;
    rdfs:label "Image — structural gate" ;

    sh:property [
        sh:path gg:sha256 ;
        sh:datatype xsd:string ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:pattern "^[0-9a-f]{64}$" ;
        sh:message "An image must have exactly one lower-case hexadecimal SHA-256 digest." ;
    ] ;

    sh:property [
        sh:path gg:mediaType ;
        sh:datatype xsd:string ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:pattern "^image/[-+.a-zA-Z0-9]+$" ;
        sh:message "An image must have exactly one image/* media type." ;
    ] ;

    sh:property [
        sh:path gg:bundlePath ;
        sh:datatype xsd:string ;
        sh:maxCount 1 ;
        sh:minLength 1 ;
    ] ;

    sh:property [
        sh:path gg:pixelWidth ;
        sh:datatype xsd:integer ;
        sh:maxCount 1 ;
        sh:minInclusive 1 ;
    ] ;

    sh:property [
        sh:path gg:pixelHeight ;
        sh:datatype xsd:integer ;
        sh:maxCount 1 ;
        sh:minInclusive 1 ;
    ] .


# ============================================================================
# HUMAN ANNOTATION SHAPES
# ============================================================================

gg:HumanAnnotationShape
    a sh:NodeShape ;
    sh:targetClass gg:HumanAnnotation ;
    rdfs:label "Human annotation — structural gate" ;

    sh:property [
        sh:path gg:annotates ;
        sh:class gg:ScanCase ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A human annotation must annotate exactly one scan case." ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A human annotation must be attributed to exactly one annotator." ;
    ] ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:annotationStatus ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:in ( gg:Draft gg:Submitted gg:Reviewed gg:Superseded ) ;
        sh:message "Annotation status must be one of Draft, Submitted, Reviewed, or Superseded." ;
    ] ;

    sh:property [
        sh:path gg:hasFieldAnnotation ;
        sh:class gg:FieldAnnotation ;
    ] ;

    sh:property [
        sh:path gg:resolvedProduct ;
        sh:class gg:CoffeeProduct ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:resolvedBatch ;
        sh:class gg:CoffeeBatch ;
        sh:maxCount 1 ;
    ] ;

    # A human annotation must say something, but that something may be only an
    # identity resolution or only one or more field observations.
    sh:or (
        [ sh:property [ sh:path gg:resolvedProduct ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:resolvedBatch ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:hasFieldAnnotation ; sh:minCount 1 ] ]
    ) ;
    sh:message "A human annotation must contain an identity resolution or at least one field annotation." .


gg:FieldAnnotationShape
    a sh:NodeShape ;
    sh:targetClass gg:FieldAnnotation ;
    rdfs:label "Field annotation — structural gate" ;

    sh:property [
        sh:path gg:field ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A field annotation must identify exactly one semantic field IRI." ;
    ] ;

    sh:property [
        sh:path gg:rawValue ;
        sh:datatype xsd:string ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:normalizedValue ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:observationState ;
        sh:maxCount 1 ;
        sh:in ( gg:Observed gg:NotVisible gg:Uncertain ) ;
    ] ;

    # Omission of a field is normally enough. If a FieldAnnotation node exists, it must
    # contain either a literal/raw value, a normalized value, or an explicit state.
    sh:or (
        [ sh:property [ sh:path gg:rawValue ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:normalizedValue ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:observationState ; sh:minCount 1 ] ]
    ) ;
    sh:message "A field annotation must contain a raw value, normalized value, or explicit observation state." .


# ============================================================================
# CURATED GROUND-TRUTH SHAPES
# ============================================================================

gg:GroundTruthShape
    a sh:NodeShape ;
    sh:targetClass gg:GroundTruth ;
    rdfs:label "Ground truth — curation gate" ;

    sh:property [
        sh:path gg:groundTruthFor ;
        sh:class gg:ScanCase ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A ground-truth snapshot must belong to exactly one scan case." ;
    ] ;

    sh:property [
        sh:path gg:basedOnAnnotation ;
        sh:class gg:HumanAnnotation ;
        sh:minCount 1 ;
        sh:message "Ground truth must cite at least one human annotation as evidence." ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "Ground truth must be attributed to exactly one curator." ;
    ] ;

    sh:property [
        sh:path dct:created ;
        sh:datatype xsd:dateTime ;
        sh:minCount 1 ; sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:resolvedProduct ;
        sh:class gg:CoffeeProduct ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:resolvedBatch ;
        sh:class gg:CoffeeBatch ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:hasTruthField ;
        sh:class gg:TruthField ;
    ] ;

    sh:or (
        [ sh:property [ sh:path gg:resolvedProduct ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:resolvedBatch ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:hasTruthField ; sh:minCount 1 ] ]
    ) ;
    sh:message "Ground truth must curate an identity or at least one field." .


gg:TruthFieldShape
    a sh:NodeShape ;
    sh:targetClass gg:TruthField ;
    rdfs:label "Ground-truth field — curation gate" ;

    sh:property [
        sh:path gg:truthField ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A ground-truth field must identify exactly one canonical field IRI." ;
    ] ;

    sh:property [
        sh:path gg:truthValue ;
        sh:maxCount 1 ;
        sh:message "A ground-truth field carries at most one curated value." ;
    ] ;

    sh:property [
        sh:path gg:observationState ;
        sh:in ( gg:Observed gg:NotVisible gg:Uncertain ) ;
        sh:maxCount 1 ;
        sh:message "Observation state must be one of the three gg:ObservationState terms." ;
    ] ;

    # A truth field must assert SOMETHING: either a curated value, or the curated
    # judgement that the images do not establish one. Requiring gg:truthValue outright
    # would make curated absence unstatable, and an extraction test case needs it — that
    # is what distinguishes "the extractor must not invent a value here" from "this field
    # was never assessed, so it is not scored".
    sh:or (
        [ sh:property [ sh:path gg:truthValue ; sh:minCount 1 ] ]
        [ sh:property [ sh:path gg:observationState ; sh:minCount 1 ] ]
    ) ;
    sh:message "A ground-truth field must carry a value or an observation state." ;

    sh:property [
        sh:path gg:basedOnFieldAnnotation ;
        sh:class gg:FieldAnnotation ;
    ] .


# ============================================================================
# SCHEMA FEEDBACK SHAPES
# ============================================================================

gg:SchemaNoteShape
    a sh:NodeShape ;
    sh:targetClass gg:SchemaNote ;
    rdfs:label "Schema note — structural gate" ;

    sh:property [
        sh:path gg:target ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A schema note must target exactly one property IRI." ;
    ] ;

    sh:property [
        sh:path gg:kind ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:in ( gg:MissingEnumValue gg:NeedsMultipleValues gg:Ambiguous
                gg:ShouldSplit gg:NotOnBags gg:ProposeField ) ;
        sh:message "Schema note kind must use the Grindograph closed vocabulary." ;
    ] ;

    sh:property [
        sh:path gg:provokedBy ;
        sh:class gg:ScanCase ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A schema note must be anchored to exactly one scan case." ;
    ] ;

    sh:or (
        [ sh:not [ sh:property [
            sh:path gg:kind ;
            sh:in ( gg:MissingEnumValue gg:ProposeField ) ;
            sh:minCount 1
        ] ] ]
        [ sh:property [
            sh:path gg:detail ;
            sh:datatype xsd:string ;
            sh:minCount 1 ;
            sh:minLength 1
        ] ]
    ) ;
    sh:message "MissingEnumValue and ProposeField notes must carry non-empty gg:detail." .


gg:ShapeProposalShape
    a sh:NodeShape ;
    sh:targetClass gg:ShapeProposal ;
    rdfs:label "Shape proposal — structural gate" ;

    sh:property [
        sh:path gg:proposedPath ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A shape proposal must name exactly one proposed property path." ;
    ] ;

    sh:property [
        sh:path gg:proposedDatatype ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path gg:proposedMaxCount ;
        sh:datatype xsd:integer ;
        sh:maxCount 1 ;
        sh:minInclusive 1 ;
    ] ;

    sh:property [
        sh:path gg:proposedNodeKind ;
        sh:nodeKind sh:IRI ;
        sh:maxCount 1 ;
    ] ;

    sh:property [
        sh:path prov:wasAttributedTo ;
        sh:nodeKind sh:IRI ;
        sh:minCount 1 ; sh:maxCount 1 ;
        sh:message "A shape proposal must be attributed to exactly one contributor." ;
    ] .
