#!/usr/bin/env python3
"""Read-only HubSpot/Facebook integration record check. No account access or mutation."""
import base64, json, sys
from datetime import datetime
from pathlib import Path

SCHEMA="maestro.hubspot-facebook-integration.v1"
CHECK_SCHEMA="maestro.hubspot-facebook-check.v1"
STEPS=("hubspot_ads_permission","marketing_contacts_permission","facebook_user_authorization","ad_account_admin","account_status","app_authorization","page_app_permission","connection","reporting","auto_tracking","ad_format","destination_url","tracking_parameters","lead_terms_acceptance","lead_delivery","submission","contact","contact_event","routing")
COUNTS=("submitted","accepted","rejected","contacts","contact_events","routed_contacts","duplicate_submissions","unresolved")
SCOPE_KEYS=("hubspot_portal_id","facebook_business_manager_id","facebook_ad_account_id","facebook_page_id","facebook_form_id")
SUPPORTED={"Website Traffic","Single Image","Video","Standard Carousel","Advantage+"}
UNSUPPORTED={"Messenger","Placement Asset Customization Carousel","No External Destination","Facebook Lead Ad"}
NATIVE_TYPES={"native_readback","native_receipt","source_report","fictional_example_receipt"}
REQUEST_IDS={
 "hubspot_ads_permission":"REQ_HUBSPOT_ADS_PUBLISH_ACCESS","marketing_contacts_permission":"REQ_MARKETING_CONTACTS_PERMISSION","facebook_user_authorization":"REQ_FACEBOOK_USER_AUTHORIZATION","ad_account_admin":"REQ_AD_ACCOUNT_ADMIN","account_status":"REQ_CONNECTION_READBACK","app_authorization":"REQ_CONNECTION_READBACK","page_app_permission":"REQ_PAGE_APP_PERMISSION","connection":"REQ_CONNECTION_READBACK","reporting":"REQ_CONNECTION_READBACK","auto_tracking":"REQ_AUTO_TRACKING_READBACK","ad_format":"REQ_AD_URL_TRACKING_READBACK","destination_url":"REQ_AD_URL_TRACKING_READBACK","tracking_parameters":"REQ_AD_URL_TRACKING_READBACK","lead_terms_acceptance":"REQ_LEAD_TERMS_ACCEPTANCE","lead_delivery":"REQ_TEST_SUBMISSION_POPULATION","submission":"REQ_TEST_SUBMISSION_POPULATION","contact":"REQ_CONTACT_EVENT_READBACK","contact_event":"REQ_CONTACT_EVENT_READBACK","routing":"REQ_ROUTING_READBACK","test_authorization":"REQ_TEST_SUBMISSION_POPULATION","population_evidence":"REQ_TEST_SUBMISSION_POPULATION","population_reconciliation":"REQ_POPULATION_RECONCILIATION","planning":"REQ_CONNECTION_READBACK","import_contract":"REQ_POPULATION_RECONCILIATION"}
OWNERS={"hubspot_ads_permission":"HubSpot portal admin","marketing_contacts_permission":"HubSpot portal admin","facebook_user_authorization":"Facebook Business Manager owner","ad_account_admin":"Facebook Business Manager owner","account_status":"Facebook ad-account owner","app_authorization":"Facebook integration owner","page_app_permission":"Facebook Page admin","connection":"HubSpot Ads owner","reporting":"HubSpot Ads owner","auto_tracking":"HubSpot Ads owner","ad_format":"Facebook campaign owner","destination_url":"Facebook campaign owner","tracking_parameters":"HubSpot Ads owner","lead_terms_acceptance":"Facebook Primary Connected User","lead_delivery":"HubSpot Ads owner","submission":"Facebook form owner","contact":"HubSpot CRM owner","contact_event":"HubSpot CRM owner","routing":"HubSpot workflow owner","test_authorization":"Facebook form owner","population_evidence":"Marketing operations owner","population_reconciliation":"Marketing operations owner","planning":"Marketing operations owner","import_contract":"Record owner"}
ACTIONS={
 "hubspot_ads_permission":"Read back Publish access to HubSpot Ads for the connecting user in the named portal.","marketing_contacts_permission":"Read back Marketing contacts access for the operator creating the lead ad.","facebook_user_authorization":"Read back the signed-in Facebook user for the intended Business Manager.","ad_account_admin":"Read back ad-account admin access with Manage Ad Account enabled for the exact user and ad account.","account_status":"Read back the exact ad-account status and resolve a disabled or inactive state before reconnecting.","app_authorization":"Read back every required HubSpot Ads business-integration permission before considering a reset.","page_app_permission":"Read back the relevant Page permission switches for the exact Page ID.","connection":"Read back the connected ad-account ID and primary Facebook user in HubSpot Ads settings.","reporting":"Read back the exact campaign/ad and selected date range in HubSpot Ads.","auto_tracking":"Read back auto-tracking for this exact ad account.","ad_format":"Read back the actual Facebook ad format and whether it has an eligible external destination.","destination_url":"Read back the final destination URL from the actual ad.","tracking_parameters":"Read back the parameters applied to the actual eligible ad; a planned template is not evidence.","lead_terms_acceptance":"Read back Lead Ad Terms acceptance for the exact Page by the Primary Connected User.","lead_delivery":"Read back the selected Page/form scope and the resulting HubSpot lead-delivery state.","submission":"Provide the authorized Facebook submission ID and timestamp for the exact form and cutoff.","contact":"Read back the matching HubSpot contact ID and whether it existed before the submission.","contact_event":"Read back the matching ad or form activity on that HubSpot contact.","routing":"Read back the actual list, workflow or owner receipt for the contact and test window.","test_authorization":"Provide the form owner's dated authorization before the cutoff starts.","population_evidence":"Provide a dated same-scope native count. Keep blank, Unknown and evidenced zero distinct.","population_reconciliation":"Name each unresolved submission ID, its last proven receipt and the next system owner.","planning":"Complete the named planned field without inventing an observed result.","import_contract":"Repair the named field and reevaluate the preserved source."}
INPUT_RAW=""; INPUT_BYTES=b""
PLACEHOLDERS={"","unknown","n/a","na","tbd","none","null","-","—"}

def missing_value(v):
 return not isinstance(v,str) or v.strip().lower() in PLACEHOLDERS

def kind(v):
 if v is None:return "null"
 if isinstance(v,bool):return "boolean"
 if isinstance(v,dict):return "object"
 if isinstance(v,list):return "array"
 if isinstance(v,str):return "string"
 if isinstance(v,(int,float)):return "number"
 return type(v).__name__

def timestamp(v):
 if not isinstance(v,str) or not v:return False
 try:return datetime.fromisoformat(v.replace("Z","+00:00")).tzinfo is not None
 except ValueError:return False

def instant(v):return datetime.fromisoformat(v.replace("Z","+00:00"))
def diag(path,expected,value,message,missing=False):return {"path":path,"expected_kind":expected,"received_kind":"missing" if missing else kind(value),"value":"<missing>" if missing else value,"message":message}

def result(source,disposition,state,path,reason,diagnostics=()):
 owner=OWNERS.get(state,"Record owner")
 obs=source.get("observations",{}) if isinstance(source,dict) and isinstance(source.get("observations"),dict) else {}
 if state in obs and isinstance(obs[state],dict) and isinstance(obs[state].get("owner"),str) and obs[state]["owner"].strip():owner=obs[state]["owner"].strip()
 request=None
 if state:
  request={"id":REQUEST_IDS.get(state,"REQ_POPULATION_RECONCILIATION"),"owner":owner,"path":path,"receipt":ACTIONS.get(state,ACTIONS["import_contract"]),"reason":reason,"privacy_note":"Keep personal data, tokens and credentials in approved systems; share only safe IDs or redacted receipt references."}
 return {"schema_version":CHECK_SCHEMA,"disposition":disposition,"first_broken_state":state,"path":path,"reason":reason,"next_request":request,"diagnostics":list(diagnostics),"source":source,"input_preserved_json":INPUT_RAW,"input_bytes_base64":base64.b64encode(INPUT_BYTES).decode()}

def evaluate(source):
 if not isinstance(source,dict):
  d=diag("$","object",source,"Record must be an object.");return result(source,"REPAIR_REQUIRED","import_contract","$",d["message"],[d])
 errors=[]
 def req(obj,path,key,expected):
  if key not in obj:errors.append(diag(path+key,expected,None,"Required member is missing.",True));return
  if kind(obj[key])!=expected:errors.append(diag(path+key,expected,obj[key],"Required member has the wrong kind."))
 for k,t in (("schema_version","string"),("record_id","string"),("example_only","boolean"),("evaluation_phase","string"),("scope","object"),("plan","object"),("cutoff","object"),("observations","object"),("populations","object"),("evidence","array")):req(source,"",k,t)
 if errors:return result(source,"REPAIR_REQUIRED","import_contract",errors[0]["path"],errors[0]["message"],errors)
 if source["schema_version"]!=SCHEMA or source["evaluation_phase"] not in ("draft","launch","post_test"):
  path="schema_version" if source["schema_version"]!=SCHEMA else "evaluation_phase";d=diag(path,"supported literal",source[path],"Unsupported schema or phase.");return result(source,"REPAIR_REQUIRED","import_contract",path,d["message"],[d])
 scope,plan,cutoff,obs,pops=(source[k] for k in ("scope","plan","cutoff","observations","populations"))
 scope_required=("organization","hubspot_portal_id","hubspot_user_ref","facebook_business_manager_id","facebook_user_ref","facebook_ad_account_id","facebook_page_id","facebook_form_id","campaign_id","ad_id","ad_format")
 plan_required=("connection_owner","lead_delivery_owner","routing_owner","destination_url","planned_route","test_authorized_by","test_authorized_at","test_authorization_ref")
 cutoff_required=("start","end","timezone","cohort")+SCOPE_KEYS
 for k in scope_required:req(scope,"scope.",k,"string")
 for k in plan_required:req(plan,"plan.",k,"string")
 for k in cutoff_required:req(cutoff,"cutoff.",k,"string")
 for step in STEPS:
  req(obs,"observations.",step,"object")
  row=obs.get(step)
  if isinstance(row,dict):
   for k in ("state","value","evidence_ref","observed_at","owner"):req(row,f"observations.{step}.",k,"string")
   if isinstance(row.get("state"),str) and row["state"] not in ("BLANK","UNKNOWN","PASS","FAIL","NOT_APPLICABLE"):errors.append(diag(f"observations.{step}.state","BLANK|UNKNOWN|PASS|FAIL|NOT_APPLICABLE",row["state"],"Invalid observation state."))
 for name in COUNTS:
  req(pops,"populations.",name,"object");row=pops.get(name)
  if isinstance(row,dict):
   for k in ("state","evidence_ref","captured_at","definition"):req(row,f"populations.{name}.",k,"string")
   req(row,f"populations.{name}.","scope","object")
   if "value" not in row:errors.append(diag(f"populations.{name}.value","null or nonnegative integer",None,"Required member is missing.",True))
   elif row.get("state")=="KNOWN" and (kind(row["value"])!="number" or not isinstance(row["value"],int) or row["value"]<0 or row["value"]>9007199254740991):errors.append(diag(f"populations.{name}.value","safe nonnegative integer",row["value"],"Known count needs a safe nonnegative integer."))
   elif row.get("state") in ("BLANK","UNKNOWN") and row["value"] is not None:errors.append(diag(f"populations.{name}.value","null",row["value"],"Blank and Unknown cannot have a count."))
   if row.get("state") not in ("BLANK","UNKNOWN","KNOWN"):errors.append(diag(f"populations.{name}.state","BLANK|UNKNOWN|KNOWN",row.get("state"),"Invalid population state."))
 for i,e in enumerate(source["evidence"]):
  if not isinstance(e,dict):errors.append(diag(f"evidence[{i}]","object",e,"Evidence member must be an object."));continue
  for k in ("id","type","observed_at","scope","detail"):req(e,f"evidence[{i}].",k,"object" if k=="scope" else "string")
 if errors:return result(source,"REPAIR_REQUIRED","import_contract",errors[0]["path"],errors[0]["message"],errors)
 if source["example_only"] is False and any(e["type"]=="fictional_example_receipt" for e in source["evidence"]):return result(source,"REPAIR_REQUIRED","import_contract","evidence","Fictional evidence cannot verify a live record.")
 identity_order=(("organization","planning"),("hubspot_portal_id","planning"),("hubspot_user_ref","hubspot_ads_permission"),("facebook_business_manager_id","facebook_user_authorization"),("facebook_user_ref","facebook_user_authorization"),("facebook_ad_account_id","connection"),("facebook_page_id","page_app_permission"),("facebook_form_id","planning"),("campaign_id","reporting"),("ad_id","reporting"),("ad_format","ad_format"))
 for field,state in identity_order:
  if missing_value(scope[field]):return result(source,"LAUNCH_HOLD",state,f"scope.{field}","A named identity value is required; blank and placeholder values do not establish scope.")
 for field in ("connection_owner","lead_delivery_owner","routing_owner","planned_route"):
  if missing_value(plan[field]):return result(source,"LAUNCH_HOLD","planning",f"plan.{field}","A named owner or route is required; blank and placeholder values do not complete the plan.")
 evidence={e["id"]:e for e in source["evidence"] if e["id"]}
 expected={k:scope[k] for k in SCOPE_KEYS}
 def valid_receipt(ref,at):
  e=evidence.get(ref);return bool(e and e["type"] in NATIVE_TYPES and timestamp(e["observed_at"]) and e["observed_at"]==at and all(e["scope"].get(k)==v for k,v in expected.items()) and e["detail"].strip())
 for k in SCOPE_KEYS:
  if cutoff[k] and scope[k] and cutoff[k]!=scope[k]:return result(source,"REPAIR_REQUIRED","import_contract","cutoff."+k,"Cutoff scope differs from the planned identity.")
 if scope["ad_format"] and scope["ad_format"] not in SUPPORTED|UNSUPPORTED:return result(source,"REPAIR_REQUIRED","import_contract","scope.ad_format","Ad format is outside the documented Facebook support boundary used by this record.")
 missing=["scope."+k for k in scope_required if not scope[k] and k not in ("destination_url",)]+["plan."+k for k in ("connection_owner","lead_delivery_owner","routing_owner","planned_route") if not plan[k]]+["cutoff."+k for k in cutoff_required if not cutoff[k]]
 if scope["ad_format"] in SUPPORTED and scope["ad_format"]!="Facebook Lead Ad" and not plan["destination_url"]:missing.append("plan.destination_url")
 if missing:return result(source,"LAUNCH_HOLD","planning",missing[0],"Planned scope is incomplete: "+", ".join(missing))
 if not timestamp(cutoff["start"]) or not timestamp(cutoff["end"]) or instant(cutoff["start"])>=instant(cutoff["end"]):return result(source,"REPAIR_REQUIRED","import_contract","cutoff","Cutoff needs ordered ISO-8601 timestamps with time zones.")
 if source["evaluation_phase"]!="draft":
  auth=evidence.get(plan["test_authorization_ref"])
  if not plan["test_authorized_by"] or not timestamp(plan["test_authorized_at"]) or instant(plan["test_authorized_at"])>=instant(cutoff["start"]) or not auth or auth["type"] not in ("operator_authorization","fictional_example_receipt") or (auth["type"]=="fictional_example_receipt" and not source["example_only"]) or auth["observed_at"]!=plan["test_authorized_at"] or any(auth["scope"].get(k)!=v for k,v in expected.items()):return result(source,"LAUNCH_HOLD","test_authorization","plan.test_authorization_ref","The test lacks a dated same-scope owner authorization receipt.")
 # Validate observed receipt shape before using any PASS or FAIL.
 for step,row in obs.items():
  if row["state"] in ("PASS","FAIL") and (not row["value"].strip() or not valid_receipt(row["evidence_ref"],row["observed_at"])):return result(source,"LAUNCH_HOLD",step,f"observations.{step}","An observed state needs a specific dated same-scope native receipt.")
 later_blockers=[]
 unresolved_row=pops["unresolved"]
 if unresolved_row["state"]=="KNOWN" and isinstance(unresolved_row["value"],int) and not isinstance(unresolved_row["value"],bool) and unresolved_row["value"]>0:
  later_blockers.append({"path":"populations.unresolved.value","expected_kind":"zero unresolved submissions","received_kind":"number","value":unresolved_row["value"],"message":f"{unresolved_row['value']} submission(s) remain unresolved at the cutoff.","state":"population_reconciliation","request_id":"REQ_POPULATION_RECONCILIATION"})
 # Walk identity, permission, connection, tracking and delivery in operational order
 # before evaluating downstream population reconciliation.
 expected_values={"hubspot_ads_permission":{"Publish"},"marketing_contacts_permission":{"Granted"},"facebook_user_authorization":{scope["facebook_user_ref"]},"ad_account_admin":{"Admin","Manage Ad Account"},"account_status":{"active"},"app_authorization":{"current"},"page_app_permission":{"all_required_on"},"connection":{scope["facebook_ad_account_id"]},"auto_tracking":{"on"},"ad_format":{scope["ad_format"]},"destination_url":{plan["destination_url"]},"lead_terms_acceptance":{"accepted"}}
 if source["evaluation_phase"]!="draft":
  for step in STEPS:
   row=obs[step]
   if scope["ad_format"] in UNSUPPORTED and step in ("auto_tracking","destination_url","tracking_parameters"):
    if row["state"]!="NOT_APPLICABLE" or not row["value"].strip():return result(source,"LAUNCH_HOLD",step,f"observations.{step}","Document the unsupported path and source; do not claim tracking passed.",later_blockers)
    continue
   if row["state"]=="FAIL":return result(source,"REPAIR_REQUIRED",step,f"observations.{step}","Observed failure: "+row["value"],later_blockers)
   if row["state"]!="PASS":return result(source,"LAUNCH_HOLD",step,f"observations.{step}","Native state is blank or Unknown; obtain the next receipt.",later_blockers)
   if step in expected_values and row["value"] not in expected_values[step]:return result(source,"REPAIR_REQUIRED",step,f"observations.{step}.value","Observed value does not match the declared identity or required state.",later_blockers)
  if scope["campaign_id"] not in obs["reporting"]["value"] or scope["ad_id"] not in obs["reporting"]["value"]:return result(source,"REPAIR_REQUIRED","reporting","observations.reporting.value","Reporting receipt omits the scoped campaign or ad ID.")
  if scope["facebook_page_id"] not in obs["lead_delivery"]["value"] or scope["facebook_form_id"] not in obs["lead_delivery"]["value"]:return result(source,"REPAIR_REQUIRED","lead_delivery","observations.lead_delivery.value","Lead-delivery receipt omits the scoped Page or form ID.")
 # Validate population receipts and contradictions after the first-broken-state walk.
 known={}
 for name,row in pops.items():
  if row["state"]=="KNOWN":
   if not all(row["scope"].get(k)==v for k,v in cutoff.items()) or not timestamp(row["captured_at"]) or instant(row["captured_at"])<instant(cutoff["end"]) or not valid_receipt(row["evidence_ref"],row["captured_at"]):return result(source,"LAUNCH_HOLD","population_evidence",f"populations.{name}","Count lacks a same-scope dated native receipt.")
   known[name]=row["value"]
 if known.get("unresolved",0)>0:return result(source,"REPAIR_REQUIRED","population_reconciliation","populations.unresolved.value",f"{known['unresolved']} submission(s) remain unresolved at the cutoff.")
 contradiction=(("submission","submitted"),("lead_delivery","accepted"),("contact","contacts"),("contact_event","contact_events"),("routing","routed_contacts"))
 for step,name in contradiction:
  if obs[step]["state"]=="PASS" and known.get(name)==0:return result(source,"REPAIR_REQUIRED",step,f"populations.{name}.value",f"{step} is PASS while the evidenced {name} population is zero.")
 if all(k in known for k in COUNTS):
  if known["submitted"]!=known["accepted"]+known["rejected"]+known["unresolved"]:return result(source,"REPAIR_REQUIRED","population_reconciliation","populations","Submitted IDs must partition into accepted, rejected and unresolved.")
  checks=((known["contacts"]<=known["accepted"],"Distinct contacts exceed accepted submissions."),(known["routed_contacts"]<=known["contacts"],"Routed contacts exceed distinct contacts."),(known["duplicate_submissions"]<=known["accepted"],"Duplicate submissions exceed accepted submissions."))
  for ok,msg in checks:
   if not ok:return result(source,"REPAIR_REQUIRED","population_reconciliation","populations",msg)
  minimum=max(1 if known["accepted"] else 0,known["accepted"]-known["duplicate_submissions"])
  if known["contacts"]<minimum or known["contact_events"]<minimum or known["routed_contacts"]<minimum:return result(source,"REPAIR_REQUIRED","population_reconciliation","populations","Accepted submissions lack the minimum contact, contact-event or routing receipts after duplicate mapping.")
 if source["evaluation_phase"]=="draft":return result(source,"DRAFT_READY",None,None,"Planned record is reviewable; native verification is still pending.")
 if any(pops[n]["state"]!="KNOWN" for n in COUNTS):
  name=next(n for n in COUNTS if pops[n]["state"]!="KNOWN");return result(source,"LAUNCH_HOLD","population_evidence",f"populations.{name}","Population is blank or Unknown at the cutoff.")
 if known["submitted"]<1:return result(source,"LAUNCH_HOLD","submission","populations.submitted.value","Verification requires at least one authorized test submission.")
 return result(source,"VERIFIED",None,None,"Dated same-scope receipts and a zero-unresolved submission partition reconcile.")

def markdown(out):
 lines=["# HubSpot–Facebook integration check","",f"**Decision:** {out['disposition']}","",out["reason"],""]
 if out["next_request"]:
  q=out["next_request"];lines += ["## Next evidence request","",f"- ID: `{q['id']}`",f"- Owner: {q['owner']}",f"- Field: `{q['path']}`",f"- Receipt/action: {q['receipt']}",f"- Privacy: {q['privacy_note']}",""]
 if out["diagnostics"]:
  lines += ["## Source diagnostics",""]+[f"- `{d['path']}`: expected {d['expected_kind']}, received {d['received_kind']}; value `{json.dumps(d['value'],ensure_ascii=False,separators=(',',':'))}`." for d in out["diagnostics"]]+[""]
 lines += ["## Preserved input bytes","","The JSON result includes `input_bytes_base64` for byte-exact recovery.","","```json",out["input_preserved_json"],"```",""]
 return "\n".join(lines)

def main():
 if len(sys.argv)<2 or len(sys.argv)>3 or (len(sys.argv)==3 and sys.argv[2]!="--markdown"):
  print("usage: hubspot-facebook-checker.py INPUT.json [--markdown]",file=sys.stderr);return 2
 global INPUT_RAW,INPUT_BYTES
 INPUT_BYTES=Path(sys.argv[1]).read_bytes()
 try:INPUT_RAW=INPUT_BYTES.decode("utf-8")
 except UnicodeDecodeError:
  INPUT_RAW=INPUT_BYTES.decode("utf-8",errors="replace");d=diag("$","UTF-8 JSON text",base64.b64encode(INPUT_BYTES).decode(),"Input is not valid UTF-8.");out=result(INPUT_RAW,"REPAIR_REQUIRED","import_contract","$",d["message"],[d])
 else:
  try:
   source=json.loads(INPUT_RAW,parse_constant=lambda _: (_ for _ in ()).throw(ValueError("constant")));out=evaluate(source)
  except (json.JSONDecodeError,ValueError):
   d=diag("$","valid JSON",INPUT_RAW,"Invalid JSON text.");out=result(INPUT_RAW,"REPAIR_REQUIRED","import_contract","$","JSON could not be parsed; original text is preserved.",[d])
 print(markdown(out) if len(sys.argv)==3 else json.dumps(out,ensure_ascii=False,indent=2));return 0
if __name__=="__main__":raise SystemExit(main())
