#!/usr/bin/env python3
"""Generates the full Webnedrix schema layer (Laravel 11): migrations, models, enums."""
import os, re, shutil, collections

OUT = "/home/claude/webnedrix-schema"
shutil.rmtree(OUT, ignore_errors=True)
for d in ("database/migrations", "app/Models", "app/Enums", "app/Models/Concerns"):
    os.makedirs(f"{OUT}/{d}")

def studly(s): return "".join(p.capitalize() for p in re.split(r"[_\s]+", s))
def camel(s): x = studly(s); return x[0].lower() + x[1:]
def singular(t):
    special = {"courses": "course", "quizzes": "quiz", "case_studies": "case_study"}
    if t in special: return special[t]
    if t.endswith("ies"): return t[:-3] + "y"
    return t[:-1] if t.endswith("s") else t

# ---------------------------------------------------------------- ENUMS
ENUMS = {
 "OrgType": (["webnedrix","client","partner"], {}),
 "Currency": (["NGN","USD","GBP","EUR"], {}),
 "Visibility": (["internal","shared"], {}),
 "ContentStatus": (["draft","scheduled","published","archived"], {}),
 "PipelineStage": (["new_lead","contacted","qualified","discovery","proposal","negotiation","won","client","lost"], {
   "new_lead":["contacted","lost"],"contacted":["qualified","lost"],"qualified":["discovery","lost"],
   "discovery":["proposal","lost"],"proposal":["negotiation","lost"],"negotiation":["won","lost"],
   "won":["client"],"client":[],"lost":[]}),
 "PartnerOrderStatus": (["draft","submitted","review","accepted","in_production","qa","client_review","changes","approved","completed","commissioned","paid_out"], {
   "draft":["submitted"],"submitted":["review"],"review":["accepted"],"accepted":["in_production"],
   "in_production":["qa"],"qa":["client_review","in_production"],"client_review":["changes","approved"],
   "changes":["in_production"],"approved":["completed"],"completed":["commissioned"],
   "commissioned":["paid_out"],"paid_out":[]}),
 "PartnerApplicationStatus": (["submitted","under_review","approved","rejected","suspended"], {
   "submitted":["under_review"],"under_review":["approved","rejected"],"approved":["suspended"],
   "rejected":[],"suspended":["approved"]}),
 "PayoutStatus": (["pending","approved","processing","paid","failed","on_hold"], {
   "pending":["approved","on_hold"],"approved":["processing","on_hold"],"processing":["paid","failed"],
   "paid":[],"failed":["processing","on_hold"],"on_hold":["pending","approved"]}),
 "InvoiceStatus": (["draft","sent","partial","paid","overdue","void"], {
   "draft":["sent","void"],"sent":["partial","paid","overdue","void"],"partial":["paid","overdue","void"],
   "overdue":["partial","paid","void"],"paid":[],"void":[]}),
 "TaskStatus": (["backlog","todo","in_progress","review","blocked","done"], {
   "backlog":["todo"],"todo":["in_progress","backlog","blocked"],"in_progress":["review","blocked","todo"],
   "review":["done","in_progress"],"blocked":["todo","in_progress"],"done":["in_progress"]}),
 "ProjectPhase": (["discovery","strategy","ux_design","development","qa","launch","improvement"], {
   "discovery":["strategy"],"strategy":["ux_design"],"ux_design":["development"],
   "development":["qa"],"qa":["launch","development"],"launch":["improvement"],"improvement":[]}),
 "SolutionArea": (["digital_products","business_automation","ai","custom_software","system_integration","digital_growth"], {}),
 "CourseAccess": (["free","paid","partner_only","client_only","staff_only","invite_only"], {}),
}
def write(path, txt):
    with open(f"{OUT}/{path}", "w") as f: f.write(txt)

for name,(cases,trans) in ENUMS.items():
    body = "\n".join(f"    case {studly(c)} = '{c}';" for c in cases)
    t = ""
    if trans:
        rows = "\n".join(f"            '{k}' => [{', '.join(repr(x) for x in v)}]," for k,v in trans.items())
        t = f"""
    /** @return array<int,string> values this state may move to */
    public function transitions(): array
    {{
        return match ($this->value) {{
{chr(10).join(f"            '{k}' => [{', '.join(repr(x) for x in v)}]," for k,v in trans.items())}
            default => [],
        }};
    }}

    public function canMoveTo(self $to): bool
    {{
        return in_array($to->value, $this->transitions(), true);
    }}
"""
    write(f"app/Enums/{name}.php", f"""<?php

declare(strict_types=1);

namespace App\\Enums;

enum {name}: string
{{
{body}

    public function label(): string
    {{
        return ucwords(str_replace('_', ' ', $this->value));
    }}

    /** @return array<int,string> */
    public static function values(): array
    {{
        return array_map(fn (self $c) => $c->value, self::cases());
    }}
{t}}}
""")

# ---------------------------------------------------------------- SCHEMA DSL
# col specs: s:name  s:name?  s:name!(unique)  t: lt: i: bi: b: d: dt: j: m:(money) dec:name  e:name:Enum:default
#            fk:table[:relname][?]   morph:name[?]   cur (currency column)   sd (soft deletes)
# Model opts: imm=immutable, tbl=explicit table
S = collections.OrderedDict()
def T(table, model, cols, **o): S[table] = dict(model=model, cols=cols, **o)

T("organisations","Organisation",["s:name","s:slug!","e:type:OrgType:client","s:email?","s:phone?","s:website?","s:country?","j:settings?","sd"])
T("memberships","Membership",["fk:users","fk:organisations","s:role","b:is_primary:false"],unique=[["user_id","organisation_id"]])
T("services","Service",["s:name","s:slug!","e:solution_area:SolutionArea:digital_products","s:headline?","s:summary","t:good_fit?","t:not_yet?","j:examples?","j:how_it_works?","j:workflow_template?","b:is_key:false","b:is_published:true","i:sort:0"])
T("service_packages","ServicePackage",["fk:services","s:name","t:description?","j:features?"])
T("pricings","Pricing",["fk:service_packages","m:amount","cur","s:billing_period?","dt:valid_from?","dt:valid_to?"])
T("leads","Lead",["s:name","s:email","s:company?","s:phone?","s:need?","t:situation?","s:timeline?","s:budget_range?","s:source?","s:utm_source?","s:utm_campaign?","e:stage:PipelineStage:new_lead","fk:users:owner?","j:answers?","j:ai?","dt:qualified_at?","sd"])
T("contacts","Contact",["fk:organisations?","fk:leads?","s:name","s:email","s:phone?","s:job_title?","sd"])
T("opportunities","Opportunity",["fk:leads?","fk:contacts?","fk:organisations?","fk:users:owner?","fk:services?","s:title","e:stage:PipelineStage:new_lead","m:value?","cur","d:expected_close?","s:lost_reason?","dt:won_at?","sd"])
T("interactions","Interaction",["morph:interactable","fk:users?","s:type","t:body","e:visibility:Visibility:internal"])
T("activities","Activity",["morph:subject","fk:users:actor?","s:event","j:properties?","e:visibility:Visibility:shared"])
T("quotes","Quote",["fk:opportunities","fk:organisations?","s:number!","s:status","m:total","cur","d:valid_until?","j:items?"])
T("orders","Order",["s:reference!","fk:organisations:partner?","fk:organisations:client?","fk:services","fk:users:submitted_by?","e:status:PartnerOrderStatus:draft","t:requirements?","j:client_details?","m:amount","cur","dt:submitted_at?","sd"])
T("projects","Project",["s:name","s:slug!","fk:organisations:client","fk:opportunities?","fk:orders?","fk:services?","fk:organisations:partner?","fk:users:manager?","e:phase:ProjectPhase:discovery","s:status:active","d:starts_on?","d:due_on?","m:budget?","cur","sd"])
T("project_user","ProjectAssignment",["fk:projects","fk:users","s:role"],unique=[["project_id","user_id"]])
T("milestones","Milestone",["fk:projects","s:title","s:phase?","i:sort:0","d:due_on?","dt:completed_at?"])
T("tasks","Task",["fk:projects","fk:milestones?","fk:users:assignee?","fk:users:creator?","s:title","t:description?","e:status:TaskStatus:backlog","s:priority:normal","d:due_on?","i:sort:0","sd"])
T("task_dependencies","TaskDependency",["fk:tasks","fk:tasks:blocker"],unique=[["task_id","blocker_id"]])
T("deliverables","Deliverable",["fk:projects","fk:milestones?","s:title","t:description?","s:category","i:current_version:1"])
T("deliverable_versions","DeliverableVersion",["fk:deliverables","i:version","s:file_path","s:disk:r2","fk:users:uploader?","t:notes?","dt:locked_at?"],unique=[["deliverable_id","version"]],imm="locked")
T("approvals","Approval",["fk:deliverable_versions","fk:users","s:decision","t:comment?"],imm="always")
T("files","File",["morph:attachable","fk:organisations?","fk:users:uploader?","s:name","s:path","s:disk:r2","s:mime?","bi:size:0","s:category?","e:visibility:Visibility:shared","i:version:1","sd"])
T("documents","Document",["fk:projects?","fk:organisations?","s:title","s:type?","lt:body?","e:visibility:Visibility:shared","sd"])
T("conversations","Conversation",["fk:projects?","morph:subject?","s:title?","e:visibility:Visibility:shared"])
T("messages","Message",["fk:conversations","fk:users","t:body","e:visibility:Visibility:shared","sd"])
T("message_reads","MessageRead",["fk:messages","fk:users","dt:read_at"],unique=[["message_id","user_id"]])
T("notification_preferences","NotificationPreference",["fk:users","s:event","s:channel","b:enabled:true"],unique=[["user_id","event","channel"]])
T("invoices","Invoice",["fk:organisations:client","fk:projects?","s:number!","e:status:InvoiceStatus:draft","m:subtotal","m:total","m:amount_paid:0","cur","d:issued_on?","d:due_on?","sd"])
T("invoice_items","InvoiceItem",["fk:invoices","s:description","dec:quantity","m:unit_price","m:total"],imm="always_update")
T("payments","Payment",["fk:invoices","s:reference!","s:gateway","m:amount","cur","dec:exchange_rate?","s:status","dt:paid_at?","j:gateway_payload?"])
T("commission_rules","CommissionRule",["s:name","s:type","fk:services?","dec:rate?","m:fixed_amount?","j:tiers?","b:active:true","dt:valid_from?","dt:valid_to?"])
T("payouts","Payout",["fk:organisations:partner","s:reference!","e:status:PayoutStatus:pending","m:amount","cur","s:payment_reference?","dt:paid_at?"])
T("commissions","Commission",["fk:orders","fk:organisations:partner","fk:payouts?","m:amount","cur","j:rule_snapshot","s:status:pending"],imm="commission")
T("partner_applications","PartnerApplication",["fk:users?","fk:organisations?","e:status:PartnerApplicationStatus:submitted","j:business?","j:partnership?","j:documents?","j:portfolio?","j:references?","fk:users:reviewer?","t:review_notes?"])
T("state_histories","StateHistory",["morph:subject","s:from_state?","s:to_state","fk:users:actor?","s:actor_side?","t:note?"])
# content
T("pages","Page",["s:title","s:slug!","e:status:ContentStatus:draft","dt:publish_at?","fk:users:author?","sd"])
T("content_blocks","ContentBlock",["morph:owner","s:type","j:data?","i:sort:0"])
T("solutions","Solution",["s:title","s:slug!","e:area:SolutionArea:digital_products","t:summary?","e:status:ContentStatus:draft","i:sort:0","sd"])
T("case_studies","CaseStudy",["s:client_name","s:title","s:slug!","s:industry?","t:challenge?","t:solution?","t:technology?","j:results?","s:timeline?","j:team?","fk:projects?","e:status:ContentStatus:draft","dt:publish_at?","sd"],tbl="case_studies")
T("case_study_service","CaseStudyService",["fk:case_studies","fk:services"],unique=[["case_study_id","service_id"]],pivot=True)
T("blog_posts","BlogPost",["s:title","s:slug!","s:category?","s:type:article","t:excerpt?","lt:body?","fk:users:author?","e:status:ContentStatus:draft","dt:publish_at?","sd"])
T("faqs","Faq",["s:question","t:answer","s:group?","i:sort:0","b:is_published:true"])
T("testimonials","Testimonial",["s:author_name","s:author_role?","s:company?","t:quote","b:is_verified:false","b:is_published:false"])
T("navigations","Navigation",["s:handle!","j:items"])
T("seo_metadata","SeoMetadata",["morph:seoable","s:title?","s:description?","s:canonical?","s:og_image?","b:noindex:false","j:schema?"])
T("redirects","Redirect",["s:from_path!","s:to_path","i:status_code:301","i:hits:0"])
T("settings","Setting",["s:key!","j:value?"])
T("team_members","TeamMember",["s:name","s:role","t:bio?","s:photo_path?","i:sort:0","b:is_published:false"])
T("partner_logos","PartnerLogo",["s:name","s:logo_path?","s:url?","i:sort:0"])
T("revisions","Revision",["morph:revisable","fk:users?","j:snapshot"])
# academy
T("course_categories","CourseCategory",["s:name","s:slug!"])
T("courses","Course",["fk:course_categories?","fk:users:instructor?","s:title","s:slug!","t:summary?","e:access:CourseAccess:free","m:price?","cur","e:status:ContentStatus:draft","dt:publish_at?","sd"])
T("course_modules","Module",["fk:courses","s:title","i:sort:0"])
T("lessons","Lesson",["fk:course_modules:module","s:title","s:type","lt:body?","s:video_path?","s:file_path?","i:sort:0"])
T("quizzes","Quiz",["fk:lessons","s:title","i:pass_mark:70"])
T("quiz_questions","QuizQuestion",["fk:quizzes","t:prompt","j:options","s:correct_option","i:sort:0"])
T("quiz_attempts","QuizAttempt",["fk:quizzes","fk:users","i:score","b:passed:false","j:answers?"])
T("enrolments","Enrolment",["fk:courses","fk:users","dt:completed_at?"],unique=[["course_id","user_id"]])
T("course_purchases","CoursePurchase",["fk:courses","fk:users","s:reference!","m:amount","cur","s:gateway","s:status:pending","fk:enrolments?","dt:paid_at?","j:gateway_payload?"],index=[["status"]])
T("lesson_progress","LessonProgress",["fk:enrolments","fk:lessons","dt:completed_at?","i:last_position:0"],unique=[["enrolment_id","lesson_id"]])
T("certificates","Certificate",["fk:enrolments","s:code!","dt:issued_at","dt:revoked_at?"])
# analytics and search (no personal data: no IP, no user agent string, referrer host only)
T("page_views","PageView",["d:day","s:path","i:views:0"],unique=[["day","path"]])
T("analytics_events","AnalyticsEvent",["s:type","s:path?","s:visitor_id?","s:referrer_host?","s:utm_source?","s:utm_medium?","s:utm_campaign?","s:device?","j:meta?"],index=[["type","created_at"],["visitor_id"]])
T("ai_runs","AiRun",["s:kind","morph:subject?","s:model?","i:input_tokens:0","i:output_tokens:0","s:status:ok","t:error?","fk:users?"],index=[["created_at"],["kind","status"]])
T("search_queries","SearchQuery",["s:query","i:results:0"],index=[["created_at"]])
# platform
T("audit_logs","AuditLog",["fk:users:actor?","s:action","morph:auditable?","j:before?","j:after?","s:ip?"],imm="always")
T("domain_events","DomainEvent",["s:name","morph:subject?","j:payload?","dt:processed_at?"])
T("integrations","Integration",["s:provider","s:status:inactive","j:config?"])
T("api_keys","ApiKey",["fk:organisations?","s:name","s:key_hash!","j:scopes?","dt:last_used_at?","dt:revoked_at?"])
T("webhooks","Webhook",["fk:organisations?","s:url","s:secret","j:events?","b:active:true"])

# ---------------------------------------------------------------- PARSE
def parse(spec):
    """returns dict(kind, name, null, ...)"""
    if spec in ("sd","cur"): return dict(kind=spec)
    kind, _, rest = spec.partition(":")
    null = rest.endswith("?") or kind.endswith("?")
    unique = rest.endswith("!")
    rest = rest.rstrip("?!")
    if kind == "fk":
        parts = rest.split(":")
        table = parts[0]; rel = parts[1] if len(parts) > 1 else singular(table)
        if table == "course_modules" and len(parts) > 1: pass
        col = rel + "_id"
        return dict(kind="fk", table=table, rel=rel, col=col, null=null, default_rel=(len(parts) == 1))
    if kind == "e":
        n, enum, *d = rest.split(":")
        return dict(kind="e", name=n, enum=enum, default=(d[0] if d else None), null=null)
    if kind == "morph":
        return dict(kind="morph", name=rest, null=null)
    if kind in ("s","b","i","bi","m") and ":" in rest:
        n, d = rest.split(":", 1)
        return dict(kind=kind, name=n, default=d, null=null, unique=unique)
    return dict(kind=kind, name=rest, null=null, unique=unique, default=None)

for t, d in S.items():
    d["parsed"] = [parse(c) for c in d["cols"]]

# ---------------------------------------------------------------- MIGRATIONS
def col_line(p, table):
    k = p["kind"]; n = p.get("name")
    nl = "->nullable()" if p.get("null") else ""
    u = "->unique()" if p.get("unique") else ""
    if k == "sd": return "$table->softDeletes();"
    if k == "cur": return "$table->string('currency', 3)->default('NGN');"
    if k == "fk":
        tgt = p["table"]
        if tgt == "course_modules" and p["rel"] == "module": pass
        c = f"$table->foreignId('{p['col']}')"
        c += "->nullable()" if p["null"] else ""
        c += f"->constrained('{tgt}')"
        c += "->nullOnDelete()" if p["null"] else ("->restrictOnDelete()" if tgt in ("users","organisations","services","orders","payouts","opportunities","invoices") else "->cascadeOnDelete()")
        return c + ";"
    if k == "morph":
        return f"$table->nullableMorphs('{n}');" if p["null"] else f"$table->morphs('{n}');"
    if k == "e":
        d = f"->default('{p['default']}')" if p["default"] else ""
        return f"$table->string('{n}', 40){d}{nl}->index();"
    if k == "s":
        d = f"->default('{p['default']}')" if p.get("default") else ""
        return f"$table->string('{n}'){d}{nl}{u};"
    if k == "t": return f"$table->text('{n}'){nl};"
    if k == "lt": return f"$table->longText('{n}'){nl};"
    if k == "i":
        d = f"->default({p['default']})" if p.get("default") else ""
        return f"$table->integer('{n}'){d}{nl};"
    if k == "bi":
        d = f"->default({p['default']})" if p.get("default") else ""
        return f"$table->unsignedBigInteger('{n}'){d}{nl};"
    if k == "b":
        d = f"->default({p['default']})" if p.get("default") else ""
        return f"$table->boolean('{n}'){d}{nl};"
    if k == "d": return f"$table->date('{n}'){nl};"
    if k == "dt": return f"$table->timestamp('{n}'){nl};"
    if k == "j": return f"$table->json('{n}'){nl};"
    if k == "m":
        d = f"->default({p['default']})" if p.get("default") else ""
        return f"$table->decimal('{n}', 14, 2){d}{nl};"
    if k == "dec": return f"$table->decimal('{n}', 14, 4){nl};"
    raise ValueError(p)

for i, (t, d) in enumerate(S.items(), start=1):
    lines = [col_line(p, t) for p in d["parsed"]]
    for u in d.get("unique", []):
        lines.append("$table->unique([" + ", ".join(f"'{x}'" for x in u) + "]);")
    for u in d.get("index", []):
        lines.append("$table->index([" + ", ".join(f"'{x}'" for x in u) + "]);")
    body = "\n            ".join(lines)
    write(f"database/migrations/2026_10_28_{i:06d}_create_{t}_table.php", f"""<?php

declare(strict_types=1);

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{{
    public function up(): void
    {{
        Schema::create('{t}', function (Blueprint $table) {{
            $table->id();
            {body}
            $table->timestamps();
        }});
    }}

    public function down(): void
    {{
        Schema::dropIfExists('{t}');
    }}
}};
""")

# users extension (Laravel ships the users table)
write(f"database/migrations/2026_10_28_000000_add_webnedrix_fields_to_users_table.php", """<?php

declare(strict_types=1);

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('phone')->nullable();
            $table->string('avatar_path')->nullable();
            $table->string('locale', 10)->default('en');
            $table->string('timezone', 64)->default('Africa/Lagos');
            $table->timestamp('last_login_at')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['phone', 'avatar_path', 'locale', 'timezone', 'last_login_at']);
        });
    }
};
""")

# ---------------------------------------------------------------- MODELS
tbl2model = {t: d["model"] for t, d in S.items()}
tbl2model["users"] = "User"
inverse = collections.defaultdict(list)   # target table -> [(relname, model, col)]
for t, d in S.items():
    for p in d["parsed"]:
        if p["kind"] == "fk" and p["table"] != "users":
            name = camel(t) if p["default_rel"] else camel(p["rel"]) + studly(t)
            inverse[p["table"]].append((name, d["model"], p["col"]))
user_inverse = [(camel(t) if p["default_rel"] else camel(p["rel"]) + studly(t), d["model"], p["col"])
                for t, d in S.items() for p in d["parsed"] if p["kind"] == "fk" and p["table"] == "users"]

ENUMDIR = "App\\Enums\\"
for t, d in S.items():
    M = d["model"]; P = d["parsed"]
    fill = []; casts = []; uses = set(); rels = []
    for p in P:
        k = p["kind"]
        if k == "fk": fill.append(p["col"])
        elif k == "cur": fill.append("currency")
        elif k == "morph": fill += [p["name"] + "_type", p["name"] + "_id"]
        elif k in ("sd",): pass
        else: fill.append(p["name"])
        if k == "e": casts.append(f"'{p['name']}' => {p['enum']}::class"); uses.add(p["enum"])
        elif k == "j": casts.append(f"'{p['name']}' => 'array'")
        elif k == "m": casts.append(f"'{p['name']}' => 'decimal:2'")
        elif k == "dec": casts.append(f"'{p['name']}' => 'decimal:4'")
        elif k == "b": casts.append(f"'{p['name']}' => 'boolean'")
        elif k == "d": casts.append(f"'{p['name']}' => 'date'")
        elif k == "dt": casts.append(f"'{p['name']}' => 'datetime'")
        if k == "fk":
            tgt = tbl2model[p["table"]]
            rels.append(f"""    public function {camel(p['rel'])}(): BelongsTo
    {{
        return $this->belongsTo({tgt}::class, '{p['col']}');
    }}""")
        if k == "morph":
            rels.append(f"""    public function {camel(p['name'])}(): MorphTo
    {{
        return $this->morphTo('{p['name']}');
    }}""")
    invs = inverse.get(t, []) + (user_inverse if False else [])
    seen = set()
    for name, model, col in invs:
        if name in seen: continue
        seen.add(name)
        rels.append(f"""    public function {name}(): HasMany
    {{
        return $this->hasMany({model}::class, '{col}');
    }}""")
    traits = ["HasFactory"]
    imports = ["Illuminate\\Database\\Eloquent\\Factories\\HasFactory", "Illuminate\\Database\\Eloquent\\Model",
               "Illuminate\\Database\\Eloquent\\Relations\\BelongsTo", "Illuminate\\Database\\Eloquent\\Relations\\HasMany",
               "Illuminate\\Database\\Eloquent\\Relations\\MorphTo"]
    if "sd" in [p["kind"] for p in P]:
        traits.append("SoftDeletes"); imports.append("Illuminate\\Database\\Eloquent\\SoftDeletes")
    for e in sorted(uses): imports.append(ENUMDIR + e)
    boot = ""
    imm = d.get("imm")
    if imm == "always":
        boot = """
    protected static function booted(): void
    {
        static::updating(fn () => throw new \\LogicException(static::class . ' records are immutable.'));
        static::deleting(fn () => throw new \\LogicException(static::class . ' records cannot be deleted.'));
    }
"""
    elif imm == "locked":
        boot = """
    /** Once locked_at is set (on approval) the version can never change or be removed. */
    protected static function booted(): void
    {
        static::updating(function (self $m) {
            if ($m->getOriginal('locked_at') !== null) {
                throw new \\LogicException('An approved deliverable version is locked. Create a new version.');
            }
        });
        static::deleting(function (self $m) {
            if ($m->getOriginal('locked_at') !== null) {
                throw new \\LogicException('An approved deliverable version cannot be deleted.');
            }
        });
    }
"""
    elif imm == "commission":
        boot = """
    /** The amount and rule snapshot are frozen at creation. Later rule changes never alter them. */
    protected static function booted(): void
    {
        static::updating(function (self $m) {
            if ($m->isDirty(['amount', 'currency', 'rule_snapshot', 'order_id', 'partner_id'])) {
                throw new \\LogicException('Commission amount and rule snapshot are frozen.');
            }
        });
    }
"""
    elif imm == "always_update":
        boot = """
    /** Price at invoice time is final. Fix mistakes with a new invoice or a void, not an edit. */
    protected static function booted(): void
    {
        static::updating(fn () => throw new \\LogicException('Invoice items are immutable.'));
    }
"""
    extra = ""
    if t == "organisations":
        extra = """
    public function users(): \\Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany
    {
        return $this->belongsToMany(User::class, 'memberships')->withPivot(['role', 'is_primary'])->withTimestamps();
    }

    public function isStaffOrg(): bool
    {
        return $this->type === OrgType::Webnedrix;
    }
"""
    if t == "tasks":
        extra = """
    public function blockers(): \\Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany
    {
        return $this->belongsToMany(self::class, 'task_dependencies', 'task_id', 'blocker_id');
    }

    public function hasOpenBlockers(): bool
    {
        return $this->blockers()->where('status', '!=', TaskStatus::Done->value)->exists();
    }
"""
    if t == "case_studies":
        extra = """
    public function services(): \\Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany
    {
        return $this->belongsToMany(Service::class, 'case_study_service');
    }
"""
    if "visibility" in [p.get("name") for p in P]:
        extra += """
    public function scopeShared($query)
    {
        return $query->where('visibility', Visibility::Shared->value);
    }
"""
    if t in ("pages","blog_posts","case_studies","solutions","courses"):
        extra += """
    public function scopePublished($query)
    {
        return $query->where(function ($q) {
            $q->where(fn ($p) => $p->where('status', ContentStatus::Published->value)
                    ->where(fn ($x) => $x->whereNull('publish_at')->orWhere('publish_at', '<=', now())))
              ->orWhere(fn ($s) => $s->where('status', ContentStatus::Scheduled->value)->where('publish_at', '<=', now()));
        });
    }

    public function contentBlocks(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany
    {
        return $this->morphMany(ContentBlock::class, 'owner')->orderBy('sort');
    }
"""
    if t in ("pages","blog_posts","case_studies","solutions","courses","services"):
        extra += """
    public function seo(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphOne
    {
        return $this->morphOne(SeoMetadata::class, 'seoable');
    }

    public function revisions(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany
    {
        return $this->morphMany(Revision::class, 'revisable')->latest();
    }
"""
    if t == "projects":
        extra += """
    public function messages(): \\Illuminate\\Database\\Eloquent\\Relations\\HasManyThrough
    {
        return $this->hasManyThrough(Message::class, Conversation::class, 'project_id', 'conversation_id');
    }

    public function files(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany
    {
        return $this->morphMany(File::class, 'attachable');
    }
"""
    if t == "orders":
        extra += """
    public function conversations(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany
    {
        return $this->morphMany(Conversation::class, 'subject');
    }

    /** Messages between the partner and the Webnedrix team about this order. */
    public function messages(): \\Illuminate\\Database\\Eloquent\\Relations\\HasManyThrough
    {
        return $this->hasManyThrough(Message::class, Conversation::class, 'subject_id', 'conversation_id')
            ->where('conversations.subject_type', $this->getMorphClass());
    }
"""
    if t == "courses":
        extra += """
    public function lessons(): \\Illuminate\\Database\\Eloquent\\Relations\\HasManyThrough
    {
        return $this->hasManyThrough(Lesson::class, Module::class, 'course_id', 'module_id');
    }
"""
    if t == "course_modules":
        extra += """
    public function lessons(): HasMany
    {
        return $this->hasMany(Lesson::class, 'module_id')->orderBy('sort');
    }
"""
    if t == "services":
        extra += """
    public function caseStudies(): \\Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany
    {
        return $this->belongsToMany(CaseStudy::class, 'case_study_service');
    }
""" + """
    public function scopeLive($query)
    {
        return $query->where('is_published', true)->orderBy('sort');
    }
"""
    if t == "revisions":
        extra += """
    /** Full snapshot: model attributes plus its content blocks, taken after Filament has saved the blocks. */
    public static function snapshot(Model $record): self
    {
        $attrs = collect($record->getAttributes())->except(['id', 'created_at', 'updated_at', 'deleted_at'])->all();
        $blocks = method_exists($record, 'contentBlocks')
            ? $record->contentBlocks()->get(['type', 'data', 'sort'])->toArray() : [];

        return self::create([
            'revisable_type' => $record->getMorphClass(),
            'revisable_id' => $record->getKey(),
            'user_id' => auth()->id(),
            'snapshot' => ['attributes' => $attrs, 'blocks' => $blocks],
        ]);
    }
"""
    for e in ("Visibility","ContentStatus","TaskStatus","OrgType"):
        pass
    if "scopeShared" in extra and "Visibility" not in uses: imports.append(ENUMDIR + "Visibility")
    if "scopePublished" in extra and "ContentStatus" not in uses: imports.append(ENUMDIR + "ContentStatus")
    if "hasOpenBlockers" in extra and "TaskStatus" not in uses: imports.append(ENUMDIR + "TaskStatus")
    if "isStaffOrg" in extra and "OrgType" not in uses: imports.append(ENUMDIR + "OrgType")
    imports = sorted(set(imports))
    tblline = f"    protected $table = '{t}';\n\n"
    write(f"app/Models/{M}.php", f"""<?php

declare(strict_types=1);

namespace App\\Models;

{chr(10).join('use ' + i + ';' for i in imports)}

class {M} extends Model
{{
    use {', '.join(traits)};

{tblline}    protected $fillable = [{', '.join(repr(f) for f in fill)}];

    protected function casts(): array
    {{
        return [{', '.join(casts)}];
    }}
{boot}
{(chr(10)*2).join(rels)}
{extra}}}
""")


# ---------------------------------------------------------------- FACTORIES
os.makedirs(f"{OUT}/database/factories", exist_ok=True)
def fac_line(p, enums):
    k = p["kind"]; n = p.get("name")
    if k in ("sd",): return None
    if k == "cur": return "'currency' => 'NGN',"
    if k == "fk":
        if p["null"]: return f"'{p['col']}' => null,"
        return f"'{p['col']}' => {tbl2model[p['table']]}::factory(),"
    if k == "morph":
        if p["null"]: return None
        return f"'{n}_type' => Organisation::class, '{n}_id' => Organisation::factory(),"
    if k == "e":
        e = p["enum"]
        d = f"{e}::{studly(p['default'])}->value" if p["default"] else f"{e}::cases()[0]->value"
        return f"'{n}' => {d},"
    if p.get("null") and k in ("dt","d","t","lt","j") : return f"'{n}' => null,"
    if p.get("default") is not None and k in ("s","b","i","bi","m"):
        dv = p["default"]
        if k == "s": return f"'{n}' => '{dv}',"
        return f"'{n}' => {dv},"
    if k == "s":
        if n.endswith("slug"): return f"'{n}' => fake()->unique()->slug(),"
        if p.get("unique"): return f"'{n}' => fake()->unique()->bothify('{n[:3].upper()}-#####??'),"
        if "email" in n: return f"'{n}' => fake()->safeEmail(),"
        if n.endswith("slug"): return f"'{n}' => fake()->unique()->slug(),"
        if "phone" in n: return f"'{n}' => fake()->phoneNumber(),"
        if n in ("name","client_name","author_name"): return f"'{n}' => fake()->name(),"
        if "url" in n or n == "website": return f"'{n}' => fake()->url(),"
        return f"'{n}' => fake()->words(3, true),"
    if k in ("t","lt"): return f"'{n}' => fake()->paragraph(),"
    if k == "i": return f"'{n}' => fake()->numberBetween(1, 20),"
    if k == "bi": return f"'{n}' => fake()->numberBetween(1000, 5000000),"
    if k == "b": return f"'{n}' => fake()->boolean(),"
    if k == "d": return f"'{n}' => fake()->date(),"
    if k == "dt": return f"'{n}' => now(),"
    if k == "j": return f"'{n}' => [],"
    if k == "m": return f"'{n}' => fake()->randomFloat(2, 1000, 500000),"
    if k == "dec": return f"'{n}' => fake()->randomFloat(4, 1, 20),"
    return None

for t, d in S.items():
    M = d["model"]
    lines = [x for x in (fac_line(p, ENUMS) for p in d["parsed"]) if x]
    enum_uses = sorted({p["enum"] for p in d["parsed"] if p["kind"] == "e"})
    write(f"database/factories/{M}Factory.php", f"""<?php

declare(strict_types=1);

namespace Database\\Factories;

{chr(10).join('use App\\Enums\\' + e + ';' for e in enum_uses)}
use App\\Models\\{{{', '.join(sorted({M, 'Organisation'} | {tbl2model[p['table']] for p in d['parsed'] if p['kind']=='fk'}))}}};
use Illuminate\\Database\\Eloquent\\Factories\\Factory;

/** @extends Factory<{M}> */
class {M}Factory extends Factory
{{
    protected $model = {M}::class;

    public function definition(): array
    {{
        return [
            {(chr(10)+'            ').join(lines)}
        ];
    }}
}}
""")

# User relations trait
ur = "\n\n".join(f"""    public function {n}(): HasMany
    {{
        return $this->hasMany({m}::class, '{c}');
    }}""" for n, m, c in user_inverse)
write("app/Models/Concerns/HasWebnedrixRelations.php", f"""<?php

declare(strict_types=1);

namespace App\\Models\\Concerns;

use App\\Models\\{{{', '.join(sorted(set(m for _, m, _ in user_inverse)))}}};
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

/** Add `use HasWebnedrixRelations;` to App\\Models\\User. */
trait HasWebnedrixRelations
{{
    public function organisations(): BelongsToMany
    {{
        return $this->belongsToMany(\\App\\Models\\Organisation::class, 'memberships')->withPivot(['role', 'is_primary'])->withTimestamps();
    }}

    public function canAccessPanel(\\Filament\\Panel $panel): bool
    {{
        return $this->isStaff();
    }}

    private ?array $orgIdCache = null;
    private ?bool $staffCache = null;

    public function isStaff(): bool
    {{
        return $this->staffCache ??= $this->organisations()->where('organisations.type', 'webnedrix')->exists();
    }}

    /** @return array<int,int> organisation ids from membership only */
    public function organisationIds(): array
    {{
        return $this->orgIdCache ??= $this->organisations()->pluck('organisations.id')->map(fn ($i) => (int) $i)->all();
    }}

    /** Organisation always comes from membership, never from request input. */
    public function primaryOrganisation(): ?\\App\\Models\\Organisation
    {{
        return $this->organisations()->orderByDesc('memberships.is_primary')->first();
    }}

{ur}
}}
""")

write("README.md", f"""# Webnedrix schema layer (Laravel 11)

Generated from `gen.py`. Contents: {len(S)+1} migrations, {len(S)} models, {len(ENUMS)} enums (state machines carry allowed transitions).

## Install (inside a fresh Laravel 11 app)
```
composer create-project laravel/laravel webnedrix && cd webnedrix
composer require spatie/laravel-permission spatie/laravel-medialibrary laravel/sanctum filament/filament:"^3.2"
# copy app/ and database/ from this folder over the Laravel app, merge when asked
# in app/Models/User.php add: use App\\Models\\Concerns\\HasWebnedrixRelations;  and  use HasWebnedrixRelations; inside the class
php artisan vendor:publish --provider="Spatie\\Permission\\PermissionServiceProvider"
php artisan queue:table && php artisan migrate
```
Spatie's tables (roles, permissions, media) and Laravel's `jobs`, `notifications` come from their own migrations.

## Enforced in the model layer
- Approval and AuditLog: cannot be updated or deleted.
- DeliverableVersion: frozen once `locked_at` is set.
- Commission: amount and `rule_snapshot` frozen after creation.
- InvoiceItem: no edits after creation.
- Money: decimal(14,2) plus currency code. Quantity and rates: decimal(14,4).

Not yet written: policies, state machine classes, services, controllers, Filament, React, 3D, tests, seeders, deployment guide.
""")
print(len(S), "tables")
