// ═════════════════════════════════════════════ LEON Windows — Fenestration & Façade
// A window is two different things at once, and this module keeps them apart on
// purpose:
//
//   1. The PROFILE — a manufacturer's extrusion cross-section. That is not ours
//      to draw. It is measured geometry that comes out of the manufacturer's own
//      CAD, and the only honest way to hold it is as real coordinates in
//      millimetres, with the file it came from and a fingerprint of that file
//      still attached.
//
//   2. The ASSEMBLY — how many bays, how wide, which one opens and which way.
//      That IS ours, it is parametric, and it is redrawn from the numbers every
//      time it is shown.
//
// The rule the client wrote, kept here in their own words because it is the
// thing most likely to be quietly broken later:
//
//      "Never allow Claude/AI to look at a picture of a profile and 'guess' the
//       cross-section. If official geometry is unavailable: mark it missing.
//       Accuracy is more important than filling the library."
//
// So: the REHAU manufacturer and its systems are recorded, and every one of them
// is marked AWAITING VERIFIED MANUFACTURER CAD. There are no seeded profiles in
// this file, and nothing in this file will invent one. The importers below are
// real — a genuine DXF group-code reader and a genuine SVG path reader — and the
// moment a manufacturer DXF lands, the library fills itself from the file rather
// than from anybody's estimate.
//
// There is a SECOND way in, and reading the rule carefully is what allows it.
// "Never guess a cross-section" rules out inferring geometry from a photograph
// or a datasheet picture. It does not rule out a person typing real dimensions
// off the manufacturer's own dimensioned section — that is the same published
// data entered by hand instead of parsed, and it is how a detailer has always
// worked. So a profile can be entered by hand, and the record carries HOW it was
// obtained (see the provenance block below). A hand-entered section is tagged
// everywhere it appears — the library card, the schedule, the sections view, the
// BOM and the cut list, including the exported CSV — and a system whose profiles
// are all hand-entered is still AWAITING VERIFIED MANUFACTURER CAD, because it
// is. When the real file arrives it SUPERSEDES the hand-entered record in place,
// keeping its id so nothing that referenced it is orphaned.
//
// Anything downstream that genuinely needs a cross-section and has none — the
// sections and details, the bill of materials, cut lengths — says plainly that
// it is not available yet, instead of drawing a rectangle and calling it a jamb.

// ── Vocabulary ────────────────────────────────────────────────────────────
// Grouped the way every other drawing tool is: the work you do on a job, then
// the standards the job is built to. A profile library sitting between two
// working screens is how somebody opens it by accident.
const FEN_SW_SECTIONS = [
  { key: 'dashboard', label: 'Dashboard', icon: '📊', group: 'This job' },
  { key: 'schedule', label: 'Window Schedule', icon: '📋', group: 'This job' },
  { key: 'designer', label: 'Assembly Designer', icon: '🪟', group: 'This job' },
  { key: 'sheet', label: 'Shop Drawing', icon: '📄', group: 'This job' },
  { key: 'bom', label: 'BOM & Cut List', icon: '🧾', group: 'This job' },
  { key: 'types', label: 'Fenestration Types', icon: '🅰️', group: 'Window Settings' },
  { key: 'profiles', label: 'Profile Library', icon: '📏', group: 'Window Settings' },
  { key: 'import', label: 'Import CAD', icon: '📥', group: 'Window Settings' },
  { key: 'systems', label: 'Manufacturers & Systems', icon: '🏭', group: 'Window Settings' },
  { key: 'details', label: 'Sections & Details', icon: '✂️', group: 'Window Settings' },
];

const FEN_UNIT_SYSTEMS = ['Imperial', 'Metric'];

// Geometry validation is four states, not a boolean, because "we read the file
// and it looks wrong" and "we could not read the file" are different problems
// with different fixes.
const FEN_GEO_VALID = 'VALID';
const FEN_GEO_WARN = 'VALID WITH WARNINGS';
const FEN_GEO_REVIEW = 'REVIEW REQUIRED';
const FEN_GEO_INVALID = 'INVALID';
const FEN_GEO_STATUSES = [FEN_GEO_VALID, FEN_GEO_WARN, FEN_GEO_REVIEW, FEN_GEO_INVALID];
const FEN_GEO_TONE = {
  [FEN_GEO_VALID]: 'green', [FEN_GEO_WARN]: 'yellow',
  [FEN_GEO_REVIEW]: 'yellow', [FEN_GEO_INVALID]: 'red',
};

// The phrase is deliberately long and unambiguous. "Pending" or "TBD" would read
// as an internal chore; this reads as what it is — we are waiting on the
// manufacturer's own file and will not proceed without it.
const FEN_AWAITING_CAD = 'Awaiting Verified Manufacturer CAD';

const FEN_PROFILE_CATEGORIES = [
  'Outer Frame', 'Sash', 'Mullion', 'Transom', 'Glazing Bead', 'Threshold',
  'Track', 'Coupler', 'Adapter', 'Reinforcement', 'Trim / Add-on', 'Uncategorised',
];

// Roles are positions in an assembly, not new profiles. The same extrusion
// serves head, sill and both jambs — what differs is the transform.
const FEN_ROLES = [
  'Head', 'Sill', 'Left Jamb', 'Right Jamb', 'Sash', 'Mullion', 'Transom',
  'Glazing Bead', 'Threshold', 'Track', 'Reinforcement',
];

// ── Provenance ────────────────────────────────────────────────────────────
// HOW a cross-section was obtained, carried on the record as a field rather
// than inferred from it — because every downstream consumer (the schedule, the
// details, the BOM, the cut list) has to be able to say out loud where its
// numbers came from.
//
// The client's rule is "never guess a cross-section". Reading a section off a
// photograph or a datasheet picture is a guess and is still refused. A person
// typing real dimensions off the manufacturer's OWN dimensioned section is not:
// it is the same data entered by hand instead of parsed, and it is how a
// detailer has always worked. What it is not is the manufacturer's file — so it
// never wears that label.
const FEN_PROV_CAD = 'Verified CAD';
const FEN_PROV_DRAWING = 'Entered from dimensioned drawing';
const FEN_PROV_APPROX = 'Approximate — not for fabrication';
// A record that does not say. Treated as unverified everywhere, never as CAD.
const FEN_PROV_UNKNOWN = 'Provenance not recorded';
const FEN_PROVENANCES = [FEN_PROV_CAD, FEN_PROV_DRAWING, FEN_PROV_APPROX];
// Only the importer may set Verified CAD, so the hand editor is offered these
// two and nothing else. That is the whole guarantee, and it is one line.
const FEN_PROV_MANUAL_CHOICES = [FEN_PROV_DRAWING, FEN_PROV_APPROX];
const FEN_PROV_TONE = {
  [FEN_PROV_CAD]: 'green', [FEN_PROV_DRAWING]: 'yellow',
  [FEN_PROV_APPROX]: 'red', [FEN_PROV_UNKNOWN]: 'yellow',
};
// A short form for a table cell and the full sentence for anywhere there is
// room. The short form still never says "verified" about anything that is not.
const FEN_PROV_SHORT = {
  [FEN_PROV_CAD]: 'Verified CAD', [FEN_PROV_DRAWING]: 'Hand-entered',
  [FEN_PROV_APPROX]: 'Approximate — not for fabrication', [FEN_PROV_UNKNOWN]: 'Provenance not recorded',
};
const FEN_PROV_MEANING = {
  [FEN_PROV_CAD]: 'Parsed from the manufacturer’s own CAD file, which is still attached to this record with a fingerprint of its contents.',
  [FEN_PROV_DRAWING]: 'Typed by a person from the manufacturer’s own dimensioned section. Real published dimensions, entered by hand rather than read by a parser — check them against the named source before anything is cut.',
  [FEN_PROV_APPROX]: 'A working placeholder, so an assembly can be laid out before the real section arrives. Nothing may be fabricated from it.',
  [FEN_PROV_UNKNOWN]: 'This record does not say where its section came from, so it is treated as unverified until somebody says.',
};
// Worst-first, so a mark or a cut list built from several profiles reports the
// weakest thing it rests on rather than the strongest.
const FEN_PROV_RANK = {
  [FEN_PROV_CAD]: 0, [FEN_PROV_DRAWING]: 1, [FEN_PROV_UNKNOWN]: 2, [FEN_PROV_APPROX]: 3,
};

// What sits between the inner and outer shells of the extrusion. A vocabulary
// rather than free text, because it is the thing a thermal figure rests on and
// six spellings of "polyamide" cannot be compared.
const FEN_THERMAL_BREAKS = [
  'Not stated', 'None', 'Polyamide strip', 'PVC / foam chamber',
  'Fibre-composite', 'Thermal foam insert',
];

const FEN_OPERATIONS = [
  'Fixed', 'Casement L', 'Casement R', 'Tilt-Turn L', 'Tilt-Turn R',
  'Awning', 'Hopper', 'Slider', 'Door', 'Spandrel',
];
const FEN_OPERATION_ABBR = {
  'Fixed': 'FIX', 'Casement L': 'CSM-L', 'Casement R': 'CSM-R',
  'Tilt-Turn L': 'TT-L', 'Tilt-Turn R': 'TT-R', 'Awning': 'AWN',
  'Hopper': 'HOP', 'Slider': 'SLD', 'Door': 'DR', 'Spandrel': 'SPA',
};
// An operable bay carries a sash; a fixed light and a spandrel do not. Cut lists
// and instance generation both ask this, so it is answered once.
const FEN_OPERABLE = op => op !== 'Fixed' && op !== 'Spandrel';

const FEN_JOINT_TYPES = ['Mitre 45°', 'Butt / mechanical', 'Welded', 'Coped', 'Not set'];
const FEN_STATUSES = ['Draft', 'For Approval', 'Approved', 'Released to Production', 'Installed', 'Void'];

// ── Units ─────────────────────────────────────────────────────────────────
// DXF $INSUNITS codes, straight from the format's own table. Everything this
// module stores is millimetres, so each unit carries its factor to mm; the ones
// that are absurd for a window profile are still listed, because reading a file
// that declares itself in miles is information, not a reason to guess.
const FEN_DXF_UNITS = {
  0: { name: 'Unitless', mm: null },
  1: { name: 'Inches', mm: 25.4 },
  2: { name: 'Feet', mm: 304.8 },
  3: { name: 'Miles', mm: 1609344 },
  4: { name: 'Millimetres', mm: 1 },
  5: { name: 'Centimetres', mm: 10 },
  6: { name: 'Metres', mm: 1000 },
  7: { name: 'Kilometres', mm: 1000000 },
  8: { name: 'Microinches', mm: 0.0000254 },
  9: { name: 'Mils', mm: 0.0254 },
  10: { name: 'Yards', mm: 914.4 },
  11: { name: 'Angstroms', mm: 1e-7 },
  12: { name: 'Nanometres', mm: 1e-6 },
  13: { name: 'Microns', mm: 0.001 },
  14: { name: 'Decimetres', mm: 100 },
  15: { name: 'Decametres', mm: 10000 },
  16: { name: 'Hectometres', mm: 100000 },
  17: { name: 'Gigametres', mm: 1e12 },
  18: { name: 'Astronomical units', mm: null },
  19: { name: 'Light years', mm: null },
  20: { name: 'Parsecs', mm: null },
};

// The unit choices a human is offered on the Import Review. Deliberately short —
// a window cross-section is drawn in one of these four, and a longer list makes
// the mis-click more likely, not less.
const FEN_UNIT_CHOICES = [
  { key: 'mm', label: 'Millimetres', mm: 1 },
  { key: 'cm', label: 'Centimetres', mm: 10 },
  { key: 'in', label: 'Inches', mm: 25.4 },
  { key: 'm', label: 'Metres', mm: 1000 },
  { key: 'px', label: 'SVG user units @ 96 dpi', mm: 25.4 / 96 },
];
function fenUnitFactor(key) {
  const u = FEN_UNIT_CHOICES.find(x => x.key === key);
  return u ? u.mm : null;
}

// A profile cross-section is a small object. These are the bounds outside which
// the number on screen is far more likely to be a unit error than a real
// extrusion, and they are used only to RAISE A QUESTION, never to rescale
// anything on its own.
const FEN_PLAUSIBLE_MIN_MM = 12;
const FEN_PLAUSIBLE_MAX_MM = 600;

// ═══════════════════════════════════════════════════════ Geometry model
// Coordinates are the record. The SVG the viewer draws is a picture OF this;
// it is never the other way round.

function fenPt(x, y) { return { x: Number(x) || 0, y: Number(y) || 0 }; }

function makeProfileGeometry(data) {
  return {
    id: uid('fgeo'),
    // Real coordinates, in millimetres, after the unit factor has been applied.
    entities: [],
    // What the FILE said, kept separately from what a human confirmed — so a
    // wrong declaration in the file can still be seen after it was corrected.
    sourceUnits: null,
    sourceUnitsConfidence: 'Unknown',
    unitsUsed: null,
    unitsConfirmedBy: null,
    unitsConfirmedDate: null,
    sourceFile: '',
    sourceFormat: '',
    sourceHash: '',
    sourceBytes: 0,
    sourceText: null,      // the original file, kept intact — a source is never destroyed
    sourceDataUrl: null,   // for formats that are not text (DWG)
    importedBy: '',
    importedDate: null,
    bounds: { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, depth: 0 },
    centroid: { x: 0, y: 0 },
    origin: { x: 0, y: 0 },
    // Which way the section faces. Set by a human on import — it cannot be read
    // out of the file, and getting it wrong mirrors every detail drawn from it.
    orientation: { interiorDir: '-Y', exteriorDir: '+Y', glazingDir: '+X' },
    closedLoops: [],       // {index, kind, area, points} — outer boundary vs chambers
    features: [],          // {kind, note} — what the parser noticed and wants on the record
    unresolved: [],        // {type, reason} — read, understood, NOT silently dropped
    annotations: [],       // {type, count} — text/dimensions ignored on purpose
    validation: { status: FEN_GEO_REVIEW, issues: [], checkedDate: null },
    ...data,
  };
}

function makeFenProfile(data) {
  return {
    id: uid('fprof'),
    code: '', name: '', manufacturerId: null, systemId: null,
    category: 'Uncategorised',
    material: '', colour: '', notes: '',
    geometry: null,                        // a makeProfileGeometry, or null when none is on file
    geometryStatus: FEN_AWAITING_CAD,
    // How the geometry was obtained. NULL by default on purpose: a record that
    // never said reads as FEN_PROV_UNKNOWN and is treated as unverified. The
    // importer sets FEN_PROV_CAD explicitly; nothing else may.
    provenance: null,
    provenanceHistory: [],                 // {date, from, to, by, note} — a downgrade or an upgrade is a fact worth keeping
    sourceDocName: '',                     // the manufacturer document a hand-entered section was read off
    sourceDocFile: '', sourceDocUrl: '',   // and that document itself, attached where it can be
    tracedFromImage: false,                // traced over a picture rather than typed off stated dimensions
    copiedFromProfileId: null,
    enteredBy: '', enteredDate: null,
    // Published section figures a detailer asks for and this module shows.
    // Width and depth are deliberately NOT here — they are measured from the
    // geometry, and a typed copy of a measured number is only a second number
    // to disagree with the first.
    wallThicknessMm: null,
    glazingPocketMm: null,
    weightPerMetreKg: null,
    thermalBreak: 'Not stated',
    finishOptions: '',
    active: true,
    createdBy: '', createdDate: todayISO(),
    ...data,
  };
}

function fenProvenanceOf(profile) {
  if (!profile) return FEN_PROV_UNKNOWN;
  return profile.provenance || FEN_PROV_UNKNOWN;
}
function fenIsVerifiedCad(profile) { return fenProvenanceOf(profile) === FEN_PROV_CAD; }
function fenProvRankOf(name) { const r = FEN_PROV_RANK[name]; return r === undefined ? 2 : r; }
function fenWorstProvenance(profiles) {
  const list = (profiles || []).filter(Boolean);
  if (!list.length) return null;
  return list.map(fenProvenanceOf).reduce((a, b) => (fenProvRankOf(b) > fenProvRankOf(a) ? b : a));
}
// The distinct unverified profiles behind a set of members — what a fabrication
// warning has to name. De-duplicated, because one extrusion serving four
// positions is one thing to check, not four.
function fenUnverifiedAmong(profiles) {
  const seen = {};
  (profiles || []).forEach(p => { if (p && !fenIsVerifiedCad(p)) seen[p.id] = p; });
  return Object.keys(seen).map(k => seen[k]);
}

function makeFenSystem(data) {
  return {
    id: uid('fsys'),
    manufacturerId: null,
    name: '', family: '', material: '',
    // Published dimensions are left BLANK on purpose. They are the number the
    // import check measures against, so they have to come off the manufacturer's
    // own datasheet, typed in by a person — a remembered figure would turn this
    // check into a second guess checking the first.
    publishedWidthMm: null,
    publishedDepthMm: null,
    publishedSource: '',
    geometryStatus: FEN_AWAITING_CAD,
    notes: '',
    active: true,
    ...data,
  };
}

function makeFenManufacturer(data) {
  return {
    id: uid('fmfr'), name: '', country: '', website: '', contact: '',
    vendorId: null,               // links to a real vendor record rather than copying one
    notes: '', active: true,
    ...data,
  };
}

// A ProfileInstance is a PLACEMENT. It carries no geometry of its own — that is
// the whole point. Four jambs, a head and a sill can all be one extrusion.
function makeFenProfileInstance(data) {
  return {
    id: uid('fpi'),
    key: '',                  // stable identity across a re-sync (role + position)
    profileId: null,
    role: 'Head',
    x: 0, y: 0,               // position in the elevation, millimetres
    rotation: 0,              // degrees, counter-clockwise
    mirrorX: false, mirrorY: false,
    cutLength: null,          // null until it can be derived — never a placeholder
    jointType: 'Not set',
    qty: 1,
    notes: '',
    ...data,
  };
}

function makeFenBay(data) {
  return {
    id: uid('fbay'),
    operation: 'Fixed',
    widthMode: 'auto',        // 'auto' = share what is left equally; 'exact' = this number
    width: null,
    locked: false,            // survives "Divide equally"
    glass: '', mark: '', notes: '',
    ...data,
  };
}

function makeFenRow(data) {
  return {
    id: uid('frow'),
    heightMode: 'auto', height: null, locked: false,
    bays: [makeFenBay()],
    ...data,
  };
}

function makeFenAssembly(data) {
  return {
    id: uid('fasm'),
    width: 1800, height: 1500,          // millimetres, overall frame outside
    sillHeight: null,
    manufacturerId: null, systemId: null,
    glass: '', finish: '', glassThickness: null,
    cornerJoint: 'Mitre 45°',
    // Face widths are DECLARED, not derived — until a profile with real geometry
    // is assigned to the role, in which case the geometry supplies them and the
    // field goes read-only. Blank means "unknown", and members draw as single
    // lines rather than as invented rectangles.
    frameFaceWidth: null, mullionFaceWidth: null, transomFaceWidth: null,
    // Shim allowance to the rough opening. A declared site allowance, not a
    // manufacturer dimension.
    shimJambEach: 10, shimHead: 10, shimSill: 10,
    rows: [makeFenRow()],
    instances: [],
    profileRoles: {},                   // role -> profileId
    ...data,
  };
}

function makeFenType(data) {
  return {
    id: uid('ftype'),
    code: 'W-A', name: 'New fenestration type',
    assembly: makeFenAssembly(),
    notes: '', createdBy: '', createdDate: todayISO(),
    ...data,
  };
}

function makeFenInstance(data) {
  return {
    id: uid('finst'),
    mark: 'W101', typeId: null, qty: 1,
    location: '', level: '', elevationRef: '',
    overrides: {},                      // only what genuinely differs from the type
    status: 'Draft', notes: '',
    createdBy: '', createdDate: todayISO(),
    ...data,
  };
}

// ═══════════════════════════════════════════════ The manufacturer library
// REHAU and its systems, with no profiles under any of them — because there are
// none to put there. Each system says so in the one word a person will look for.
//
// COORDINATOR: this library is GLOBAL, not per-project, and it is held in this
// module for the session only. There is deliberately no persisted key for it
// yet: adding one means declaring state in App(), listing it in the
// savePersistedState object AND its dependency array, and putting it on ctx —
// which a module file must not do on its own. The first time verified
// manufacturer CAD is imported for real, add `fenestrationLibrary` there and
// point fenLibrary()/fenLibraryUpdate() below at ctx instead. Until then the UI
// says plainly that an import lasts until the page is reloaded.
function fenDefaultLibrary() {
  const rehau = makeFenManufacturer({
    id: 'fmfr-rehau', name: 'REHAU', country: 'Germany',
    website: 'rehau.com',
    notes: 'Systems recorded from the partnership review. No profile geometry has been supplied.',
  });
  const sys = (name, family, material, notes) => makeFenSystem({
    id: `fsys-rehau-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
    manufacturerId: rehau.id, name, family, material, notes,
  });
  return {
    manufacturers: [rehau],
    systems: [
      sys('GENEO 4700', 'GENEO', 'Fibre-composite (RAU-FIPRO)', ''),
      sys('EURODESIGN 4500', 'EURODESIGN', 'PVC-U', ''),
      sys('ASPEKT 1800', 'ASPEKT', 'PVC-U', ''),
      sys('ICONIC 700', 'ICONIC', 'PVC-U', ''),
      sys('EXELIS 190', 'EXELIS', 'PVC-U', ''),
      sys('System 2150', 'System 2150', 'PVC-U', ''),
      sys('ARTEVO', 'ARTEVO', 'PVC-U', ''),
    ],
    profiles: [],
  };
}

let FEN_LIBRARY = null;
let FEN_LIBRARY_VERSION = 0;
const FEN_LIBRARY_LISTENERS = new Set();

function fenLibrary() {
  if (!FEN_LIBRARY) FEN_LIBRARY = fenDefaultLibrary();
  return FEN_LIBRARY;
}
function fenLibraryUpdate(fn) {
  FEN_LIBRARY = fn({ ...fenLibrary() });
  FEN_LIBRARY_VERSION += 1;
  FEN_LIBRARY_LISTENERS.forEach(l => { try { l(); } catch (e) { /* a dead listener must not stop the rest */ } });
}
// ── Persistence, wired by App() ────────────────────────────────────────────
// The store stays module-level — every panel reads it synchronously and a
// module file may not declare persisted state — but it is no longer lost on
// reload. App() hydrates it once from localStorage and subscribes, so a change
// here becomes a change to the app's persisted state. This became urgent the
// moment profiles could be typed by hand: losing an hour of coordinates is a
// different order of problem from losing a re-runnable import.
function fenLibraryHydrate(saved) {
  if (!saved || typeof saved !== 'object') return;
  // Forward-merge, the same rule as mergeNewScopeFamilies: what was saved wins,
  // but a collection added to the seed since then still arrives. Without this a
  // new manufacturer could never reach anyone who had already saved a library.
  const base = fenDefaultLibrary();
  const next = { ...base };
  Object.keys(saved).forEach(k => { if (saved[k] !== undefined) next[k] = saved[k]; });
  FEN_LIBRARY = next;
  FEN_LIBRARY_VERSION += 1;
}
function fenLibrarySubscribe(fn) {
  FEN_LIBRARY_LISTENERS.add(fn);
  return () => { FEN_LIBRARY_LISTENERS.delete(fn); };
}

// A real subscription rather than new top-level React state: the store lives in
// the module, and each mounted panel re-renders when it changes.
function useFenLibrary() {
  const [, setV] = useState(FEN_LIBRARY_VERSION);
  useEffect(() => {
    const l = () => setV(FEN_LIBRARY_VERSION);
    FEN_LIBRARY_LISTENERS.add(l);
    return () => { FEN_LIBRARY_LISTENERS.delete(l); };
  }, []);
  return fenLibrary();
}
function fenSystemById(id) { return fenLibrary().systems.find(s => s.id === id) || null; }
function fenManufacturerById(id) { return fenLibrary().manufacturers.find(m => m.id === id) || null; }
function fenProfileById(id) { return fenLibrary().profiles.find(p => p.id === id) || null; }
function fenProfilesWithGeometry() {
  return fenLibrary().profiles.filter(p => p.geometry && p.geometry.entities.length
    && (p.geometry.validation.status === FEN_GEO_VALID || p.geometry.validation.status === FEN_GEO_WARN));
}
// "Usable" and "verified" are different questions and are asked separately.
// A hand-entered section is usable — it is why the module works at all before
// the CAD arrives — and it is never counted as verified.
function fenProfilesVerifiedCad() { return fenProfilesWithGeometry().filter(fenIsVerifiedCad); }
function fenProfilesHandEntered() { return fenProfilesWithGeometry().filter(p => !fenIsVerifiedCad(p)); }

// A system stops awaiting CAD only when the CAD arrives. Sections typed off a
// dimensioned drawing make the system WORKABLE and they do not end the wait —
// so the flag keeps meaning what it says.
function fenSystemStatusFor(systemId, profiles) {
  const mine = (profiles || []).filter(p => p.systemId === systemId && p.geometry && p.geometry.entities.length);
  return mine.some(fenIsVerifiedCad) ? 'Geometry on file' : FEN_AWAITING_CAD;
}
function fenApplySystemStatuses(l) {
  return { ...l, systems: l.systems.map(s => ({ ...s, geometryStatus: fenSystemStatusFor(s.id, l.profiles) })) };
}
// One write path for the library, used by the hand editor and by the importer,
// so the system flags can never be recomputed in one place and not the other.
function fenSaveProfile(profile) {
  fenLibraryUpdate(l => {
    const exists = l.profiles.some(p => p.id === profile.id);
    const profiles = exists ? l.profiles.map(p => (p.id === profile.id ? profile : p)) : [...l.profiles, profile];
    return fenApplySystemStatuses({ ...l, profiles });
  });
}

// ═══════════════════════════════════════════════ Geometry maths
// All of this operates on the coordinate records, never on anything drawn.

const FEN_TAU = Math.PI * 2;
function fenDeg(rad) { return rad * 180 / Math.PI; }
function fenRad(deg) { return deg * Math.PI / 180; }

// A bulge is DXF's way of carrying an arc inside a polyline: bulge = tan(θ/4)
// for the included angle θ. Turning it back into a centre/radius/angles arc is
// exact trigonometry, not an approximation, which is why it is done rather than
// flagged.
function fenBulgeArc(p1, p2, bulge) {
  const b = Number(bulge) || 0;
  if (!b) return null;
  const dx = p2.x - p1.x, dy = p2.y - p1.y;
  const chord = Math.sqrt(dx * dx + dy * dy);
  if (!(chord > 0)) return null;
  const theta = 4 * Math.atan(b);                 // included angle, signed
  const radius = Math.abs(chord / (2 * Math.sin(theta / 2)));
  // Centre sits on the perpendicular bisector, offset by the sagitta side.
  const mid = fenPt((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
  const h = radius * Math.cos(theta / 2);
  const sign = b > 0 ? 1 : -1;
  const ux = -dy / chord, uy = dx / chord;
  const center = fenPt(mid.x + ux * h * sign, mid.y + uy * h * sign);
  let a1 = Math.atan2(p1.y - center.y, p1.x - center.x);
  let a2 = Math.atan2(p2.y - center.y, p2.x - center.x);
  // DXF arcs run counter-clockwise from start to end; a negative bulge means the
  // arc goes the other way, so the endpoints swap rather than the direction.
  const start = b > 0 ? a1 : a2;
  const end = b > 0 ? a2 : a1;
  return { center, radius, startAngle: fenDeg(start), endAngle: fenDeg(end), reversed: b < 0 };
}

// One polyline segment at a time, straight or arc. Everything that walks a
// polyline — bounds, loops, the renderer — goes through here, so a bulge is
// handled identically everywhere or nowhere.
function fenPolylineSegments(poly) {
  const pts = poly.points || [];
  const segs = [];
  const n = pts.length;
  const last = poly.closed ? n : n - 1;
  for (let i = 0; i < last; i++) {
    const a = pts[i], b = pts[(i + 1) % n];
    if (!b) break;
    const arc = a.bulge ? fenBulgeArc(a, b, a.bulge) : null;
    segs.push(arc ? { kind: 'arc', a, b, arc } : { kind: 'line', a, b });
  }
  return segs;
}

// Sampling turns any entity into points so bounds, areas and loop-walking have
// one input shape. Arc sampling is for MEASUREMENT ONLY — the arc itself stays
// stored as centre/radius/angles, which is what a fabricator needs.
function fenArcPoints(center, radius, startDeg, endDeg, steps) {
  const out = [];
  let s = fenRad(startDeg), e = fenRad(endDeg);
  while (e < s) e += FEN_TAU;
  const n = Math.max(4, steps || Math.ceil((e - s) / (Math.PI / 24)));
  for (let i = 0; i <= n; i++) {
    const a = s + (e - s) * (i / n);
    out.push(fenPt(center.x + radius * Math.cos(a), center.y + radius * Math.sin(a)));
  }
  return out;
}

function fenEntityPoints(ent) {
  if (!ent) return [];
  switch (ent.type) {
    case 'LINE': return [ent.start, ent.end];
    case 'POLYLINE': {
      const out = [];
      fenPolylineSegments(ent).forEach(sg => {
        if (sg.kind === 'line') { out.push(sg.a, sg.b); }
        else out.push(...fenArcPoints(sg.arc.center, sg.arc.radius, sg.arc.startAngle, sg.arc.endAngle));
      });
      if (!out.length) return (ent.points || []).slice();
      return out;
    }
    case 'ARC': return fenArcPoints(ent.center, ent.radius, ent.startAngle, ent.endAngle);
    case 'CIRCLE': return fenArcPoints(ent.center, ent.radius, 0, 360);
    case 'ELLIPSE': {
      const out = [];
      const major = Math.sqrt(ent.majorAxis.x * ent.majorAxis.x + ent.majorAxis.y * ent.majorAxis.y);
      const minor = major * (ent.ratio || 1);
      const rot = Math.atan2(ent.majorAxis.y, ent.majorAxis.x);
      const s = ent.startParam === undefined ? 0 : ent.startParam;
      let e = ent.endParam === undefined ? FEN_TAU : ent.endParam;
      while (e < s) e += FEN_TAU;
      const n = 64;
      for (let i = 0; i <= n; i++) {
        const t = s + (e - s) * (i / n);
        const x = major * Math.cos(t), y = minor * Math.sin(t);
        out.push(fenPt(ent.center.x + x * Math.cos(rot) - y * Math.sin(rot),
                       ent.center.y + x * Math.sin(rot) + y * Math.cos(rot)));
      }
      return out;
    }
    default: return [];
  }
}

function fenComputeBounds(entities) {
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  (entities || []).forEach(ent => {
    fenEntityPoints(ent).forEach(p => {
      if (p.x < minX) minX = p.x;
      if (p.y < minY) minY = p.y;
      if (p.x > maxX) maxX = p.x;
      if (p.y > maxY) maxY = p.y;
    });
  });
  if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, depth: 0 };
  return { minX, minY, maxX, maxY, width: maxX - minX, depth: maxY - minY };
}

// The centroid of a closed loop, by the standard polygon formula. It falls back
// to the mean of the sampled points for an open figure, where the polygon
// centroid is meaningless — and says which it used via `approx`.
function fenPolygonArea(points) {
  let a = 0;
  for (let i = 0, n = points.length; i < n; i++) {
    const p = points[i], q = points[(i + 1) % n];
    a += p.x * q.y - q.x * p.y;
  }
  return a / 2;                       // signed: positive is counter-clockwise
}
function fenCentroidOf(points) {
  const area = fenPolygonArea(points);
  if (Math.abs(area) < 1e-9) {
    if (!points.length) return { x: 0, y: 0, approx: true };
    const s = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
    return { x: s.x / points.length, y: s.y / points.length, approx: true };
  }
  let cx = 0, cy = 0;
  for (let i = 0, n = points.length; i < n; i++) {
    const p = points[i], q = points[(i + 1) % n];
    const f = p.x * q.y - q.x * p.y;
    cx += (p.x + q.x) * f; cy += (p.y + q.y) * f;
  }
  return { x: cx / (6 * area), y: cy / (6 * area), approx: false };
}

// Closed loops are what make a cross-section a cross-section: one outer boundary
// and, in a modern window profile, several internal chambers. The largest loop
// by absolute area is the boundary; the rest are chambers. Loose lines that
// close up end-to-end are chained into loops too, because plenty of CAD exports
// a section as separate LINE and ARC entities rather than one polyline.
const FEN_JOIN_TOL = 0.05;            // mm — tighter than any real drafting error
function fenKeyFor(p) { return `${Math.round(p.x / FEN_JOIN_TOL)}|${Math.round(p.y / FEN_JOIN_TOL)}`; }

function fenDetectClosedLoops(entities) {
  const loops = [];
  const openEnds = [];
  const chainable = [];
  (entities || []).forEach(ent => {
    if (ent.type === 'CIRCLE' || ent.type === 'ELLIPSE'
      || (ent.type === 'POLYLINE' && ent.closed)) {
      const pts = fenEntityPoints(ent);
      if (pts.length > 2) loops.push({ entityIds: [ent.id], points: pts });
      return;
    }
    const pts = fenEntityPoints(ent);
    if (pts.length >= 2) chainable.push({ id: ent.id, points: pts });
  });

  // Endpoint chaining: pick an unused segment, then keep attaching whichever
  // unused segment starts or ends where the chain currently ends, until it
  // returns to where it began or runs out.
  const used = new Set();
  chainable.forEach(seed => {
    if (used.has(seed.id)) return;
    let chain = seed.points.slice();
    used.add(seed.id);
    const ids = [seed.id];
    let grew = true;
    while (grew) {
      grew = false;
      const tail = chain[chain.length - 1];
      for (const cand of chainable) {
        if (used.has(cand.id)) continue;
        const head = cand.points[0], last = cand.points[cand.points.length - 1];
        if (fenKeyFor(head) === fenKeyFor(tail)) {
          chain = chain.concat(cand.points.slice(1)); used.add(cand.id); ids.push(cand.id); grew = true; break;
        }
        if (fenKeyFor(last) === fenKeyFor(tail)) {
          chain = chain.concat(cand.points.slice(0, -1).reverse()); used.add(cand.id); ids.push(cand.id); grew = true; break;
        }
      }
    }
    if (chain.length > 2 && fenKeyFor(chain[0]) === fenKeyFor(chain[chain.length - 1])) {
      loops.push({ entityIds: ids, points: chain });
    } else if (chain.length) {
      openEnds.push({ entityIds: ids, points: chain });
    }
  });

  const scored = loops.map((l, i) => ({
    index: i, entityIds: l.entityIds, points: l.points, area: Math.abs(fenPolygonArea(l.points)),
  })).sort((a, b) => b.area - a.area);
  scored.forEach((l, i) => { l.kind = i === 0 ? 'Outer boundary' : 'Chamber'; l.index = i; });
  return { loops: scored, openChains: openEnds };
}

function fenScaleEntities(entities, f) {
  const s = p => fenPt(p.x * f, p.y * f);
  return (entities || []).map(ent => {
    switch (ent.type) {
      case 'LINE': return { ...ent, start: s(ent.start), end: s(ent.end) };
      case 'POLYLINE': return { ...ent, points: ent.points.map(p => ({ ...s(p), bulge: p.bulge || 0 })) };
      case 'ARC': return { ...ent, center: s(ent.center), radius: ent.radius * f };
      case 'CIRCLE': return { ...ent, center: s(ent.center), radius: ent.radius * f };
      case 'ELLIPSE': return { ...ent, center: s(ent.center), majorAxis: s(ent.majorAxis) };
      default: return ent;
    }
  });
}

// A content fingerprint, so a re-import of the same file is recognisable and a
// changed file is obvious. FNV-1a, 32-bit — deliberately labelled everywhere it
// is shown as a fingerprint and NOT a cryptographic hash, because a browser with
// no backend cannot promise the second thing.
function fenHash(text) {
  let h = 0x811c9dc5;
  const s = String(text || '');
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
  }
  return ('00000000' + h.toString(16)).slice(-8);
}

// ═══════════════════════════════════════════════ DXF parser
// ASCII DXF is a flat stream of PAIRS of lines: a group code, then its value.
// Everything else about the format — sections, tables, blocks, entities — is
// built out of that one rule, which is why this reads the pairs first and only
// then decides what they mean.
//
// Two details that bite anyone writing this from the spec alone, and both were
// checked against real files on this machine:
//   · AutoCAD right-aligns the code in a three-character field ("  0", " 70"),
//     so the line has to be trimmed before it is read as a number;
//   · line endings are CRLF, so the trailing \r has to go too.

function fenDxfTokens(text) {
  const lines = String(text || '').split(/\r\n|\r|\n/);
  const out = [];
  const warnings = [];
  let i = 0;
  while (i + 1 < lines.length) {
    const raw = lines[i].trim();
    if (raw === '') { i += 1; continue; }               // stray blank line, not a pair
    const code = parseInt(raw, 10);
    if (!isFinite(code) || String(code) !== raw.replace(/^\+/, '')) {
      // The stream is out of step. Skipping ONE line re-syncs a file whose pairs
      // were broken by an editor; saying so is better than reading the rest of
      // the file as nonsense.
      if (warnings.length < 5) warnings.push(`Group code expected at line ${i + 1}, found "${raw.slice(0, 24)}" — resynchronised.`);
      i += 1;
      continue;
    }
    out.push({ code, value: lines[i + 1] === undefined ? '' : lines[i + 1].trim() });
    i += 2;
  }
  return { tokens: out, warnings };
}

// A DXF record is one code-0 header plus every pair up to the next code 0.
// Codes repeat (10 appears once per polyline vertex), so the order is kept as
// well as a by-code index — a polyline needs the order, a LINE only needs the
// index, and both are cheap.
function fenDxfRecord(tokens, start) {
  const type = tokens[start].value;
  const order = [];
  const by = {};
  let i = start + 1;
  for (; i < tokens.length && tokens[i].code !== 0; i++) {
    order.push(tokens[i]);
    (by[tokens[i].code] = by[tokens[i].code] || []).push(tokens[i].value);
  }
  return { type, order, by, next: i };
}
function fenNum(by, code, fallback) {
  const v = by[code];
  if (!v || v.length === 0) return fallback === undefined ? 0 : fallback;
  const n = parseFloat(v[0]);
  return isFinite(n) ? n : (fallback === undefined ? 0 : fallback);
}
function fenStr(by, code, fallback) {
  const v = by[code];
  return v && v.length ? v[0] : (fallback === undefined ? '' : fallback);
}

// Entity types this parser understands as geometry. Anything else is recorded
// as unresolved (if it carries shape) or as an ignored annotation (if it does
// not) — never dropped without a trace, which is the difference between a
// parser and something that merely appears to work.
const FEN_DXF_GEOMETRY = ['LINE', 'LWPOLYLINE', 'POLYLINE', 'ARC', 'CIRCLE', 'ELLIPSE'];
const FEN_DXF_ANNOTATION = ['TEXT', 'MTEXT', 'DIMENSION', 'LEADER', 'MULTILEADER', 'ATTDEF', 'ATTRIB', 'POINT'];

function fenDxfEntityFrom(rec, unresolved, annotations) {
  const by = rec.by;
  const layer = fenStr(by, 8, '0');
  const base = { id: uid('fent'), layer, handle: fenStr(by, 5, '') };
  switch (rec.type) {
    case 'LINE':
      return [{ ...base, type: 'LINE',
        start: fenPt(fenNum(by, 10), fenNum(by, 20)),
        end: fenPt(fenNum(by, 11), fenNum(by, 21)) }];

    case 'LWPOLYLINE': {
      // Vertices arrive interleaved: 10 opens a point, 20 gives its y, 42 puts a
      // bulge on the point already open. Walking `order` is the only way to keep
      // a bulge attached to the right vertex.
      const pts = [];
      rec.order.forEach(t => {
        const v = parseFloat(t.value);
        if (t.code === 10) pts.push({ x: isFinite(v) ? v : 0, y: 0, bulge: 0 });
        else if (t.code === 20 && pts.length) pts[pts.length - 1].y = isFinite(v) ? v : 0;
        else if (t.code === 42 && pts.length) pts[pts.length - 1].bulge = isFinite(v) ? v : 0;
      });
      const declared = fenNum(by, 90, pts.length);
      if (declared && declared !== pts.length) {
        unresolved.push({ type: 'LWPOLYLINE', reason: `declares ${declared} vertices but ${pts.length} were read` });
      }
      const flags = fenNum(by, 70, 0);
      if (!pts.length) return [];
      return [{ ...base, type: 'POLYLINE', points: pts, closed: !!(flags & 1), source: 'LWPOLYLINE' }];
    }

    case 'ARC':
      return [{ ...base, type: 'ARC',
        center: fenPt(fenNum(by, 10), fenNum(by, 20)),
        radius: fenNum(by, 40),
        startAngle: fenNum(by, 50), endAngle: fenNum(by, 51) }];

    case 'CIRCLE':
      return [{ ...base, type: 'CIRCLE',
        center: fenPt(fenNum(by, 10), fenNum(by, 20)), radius: fenNum(by, 40) }];

    case 'ELLIPSE':
      // 11/21 is the major axis as a VECTOR from the centre, 40 the minor/major
      // ratio, 41/42 the start and end parameters in radians.
      return [{ ...base, type: 'ELLIPSE',
        center: fenPt(fenNum(by, 10), fenNum(by, 20)),
        majorAxis: fenPt(fenNum(by, 11), fenNum(by, 21)),
        ratio: fenNum(by, 40, 1),
        startParam: fenNum(by, 41, 0), endParam: fenNum(by, 42, FEN_TAU) }];

    default:
      if (FEN_DXF_ANNOTATION.includes(rec.type)) annotations.push(rec.type);
      else unresolved.push({ type: rec.type, reason: 'entity type not read as geometry' });
      return [];
  }
}

// The old heavyweight POLYLINE: the header carries the flags, then VERTEX
// records follow until SEQEND. It is handled in the entity walk rather than in
// fenDxfEntityFrom because it spans several records.
function fenDxfCollectPolyline(tokens, startIdx, unresolved) {
  const head = fenDxfRecord(tokens, startIdx);
  const flags = fenNum(head.by, 70, 0);
  const layer = fenStr(head.by, 8, '0');
  const pts = [];
  let i = head.next;
  let guard = 0;
  while (i < tokens.length && guard++ < 200000) {
    const rec = fenDxfRecord(tokens, i);
    if (rec.type === 'SEQEND') { i = rec.next; break; }
    if (rec.type === 'VERTEX') {
      const vflags = fenNum(rec.by, 70, 0);
      // Bits 16/32/64/128 mark mesh and face vertices, which are not a section.
      if (vflags & (16 | 32 | 64 | 128)) {
        unresolved.push({ type: 'VERTEX', reason: 'mesh or face vertex inside a POLYLINE — not planar section geometry' });
      } else {
        pts.push({ x: fenNum(rec.by, 10), y: fenNum(rec.by, 20), bulge: fenNum(rec.by, 42, 0) });
      }
      i = rec.next;
      continue;
    }
    break;                                  // a POLYLINE that never closed — stop here
  }
  if (flags & (16 | 64)) unresolved.push({ type: 'POLYLINE', reason: '3D mesh or polyface polyline — not a planar cross-section' });
  const ent = pts.length ? [{ id: uid('fent'), type: 'POLYLINE', layer, points: pts, closed: !!(flags & 1), source: 'POLYLINE' }] : [];
  return { entities: ent, next: i };
}

// INSERT expansion, one level. Scale, rotate, translate — in that order, which
// is the order DXF itself applies them.
function fenTransformEntity(ent, t, unresolved) {
  const { dx, dy, sx, sy, rot } = t;
  const c = Math.cos(fenRad(rot)), s = Math.sin(fenRad(rot));
  const map = p => {
    const X = p.x * sx, Y = p.y * sy;
    return fenPt(X * c - Y * s + dx, X * s + Y * c + dy);
  };
  const uniform = Math.abs(Math.abs(sx) - Math.abs(sy)) < 1e-9;
  switch (ent.type) {
    case 'LINE': return { ...ent, id: uid('fent'), start: map(ent.start), end: map(ent.end) };
    case 'POLYLINE': {
      // A mirrored insert reverses the sweep of every bulge; negating it is the
      // whole correction, and it is exact.
      const mirrored = (sx * sy) < 0;
      return { ...ent, id: uid('fent'),
        points: ent.points.map(p => ({ ...map(p), bulge: mirrored ? -(p.bulge || 0) : (p.bulge || 0) })) };
    }
    case 'ARC': {
      if (!uniform) {
        unresolved.push({ type: 'ARC', reason: 'inside an INSERT with a non-uniform scale — that turns an arc into an ellipse and is not approximated here' });
        return null;
      }
      if ((sx * sy) < 0) {
        unresolved.push({ type: 'ARC', reason: 'inside a mirrored INSERT — the sweep direction is ambiguous and is left for review rather than guessed' });
        return null;
      }
      return { ...ent, id: uid('fent'), center: map(ent.center), radius: ent.radius * Math.abs(sx),
        startAngle: ent.startAngle + rot, endAngle: ent.endAngle + rot };
    }
    case 'CIRCLE': {
      if (!uniform) {
        unresolved.push({ type: 'CIRCLE', reason: 'inside an INSERT with a non-uniform scale — that is an ellipse, not a circle' });
        return null;
      }
      return { ...ent, id: uid('fent'), center: map(ent.center), radius: ent.radius * Math.abs(sx) };
    }
    case 'ELLIPSE': {
      if (!uniform) {
        unresolved.push({ type: 'ELLIPSE', reason: 'inside an INSERT with a non-uniform scale' });
        return null;
      }
      const mc = map(ent.center);
      // The major axis is a vector, so it rotates and scales but does not translate.
      const ax = ent.majorAxis.x * sx, ay = ent.majorAxis.y * sy;
      return { ...ent, id: uid('fent'), center: mc, majorAxis: fenPt(ax * c - ay * s, ax * s + ay * c) };
    }
    default: return null;
  }
}

function fenParseDxf(text) {
  const { tokens, warnings } = fenDxfTokens(text);
  const unresolved = [];
  const annotationTypes = [];
  const entities = [];
  const blocks = {};
  const inserts = [];
  let insunits = null;
  let extMin = null, extMax = null;

  if (!tokens.length) {
    return { ok: false, entities: [], warnings: ['Nothing readable in this file — it is not an ASCII DXF. A DXF saved in binary form cannot be read here.'],
             unresolved: [], annotations: [], declaredUnits: null, unitsConfidence: 'Unknown' };
  }

  // Pass 1 — sections. HEADER for the units, BLOCKS for the definitions, and
  // ENTITIES for the drawing itself.
  let i = 0;
  let section = null;
  let guard = 0;
  while (i < tokens.length && guard++ < 2000000) {
    const t = tokens[i];
    if (t.code !== 0) { i += 1; continue; }

    if (t.value === 'SECTION') {
      const rec = fenDxfRecord(tokens, i);
      section = fenStr(rec.by, 2, '');
      i = rec.next;
      continue;
    }
    if (t.value === 'ENDSEC') { section = null; i += 1; continue; }
    if (t.value === 'EOF') break;

    if (section === 'HEADER') {
      // Header variables are a code 9 name followed by the value pairs, so the
      // record walk here is over the variable, not over an entity.
      const rec = fenDxfRecord(tokens, i);
      i = rec.next;
      continue;
    }

    if (section === 'BLOCKS' && t.value === 'BLOCK') {
      const head = fenDxfRecord(tokens, i);
      const name = fenStr(head.by, 2, fenStr(head.by, 3, ''));
      const base = fenPt(fenNum(head.by, 10), fenNum(head.by, 20));
      const collected = [];
      let j = head.next;
      let bguard = 0;
      while (j < tokens.length && bguard++ < 500000) {
        const rec2 = fenDxfRecord(tokens, j);
        if (rec2.type === 'ENDBLK') { j = rec2.next; break; }
        if (rec2.type === 'POLYLINE') {
          const got = fenDxfCollectPolyline(tokens, j, unresolved);
          collected.push(...got.entities); j = got.next; continue;
        }
        if (rec2.type === 'INSERT') {
          // One level is the promise, so a block that itself inserts another is
          // recorded rather than silently flattened wrongly.
          unresolved.push({ type: 'INSERT', reason: `nested block reference inside block "${name}" — only one level is expanded` });
          j = rec2.next; continue;
        }
        collected.push(...fenDxfEntityFrom(rec2, unresolved, annotationTypes));
        j = rec2.next;
      }
      blocks[name] = { name, base, entities: collected };
      i = j;
      continue;
    }

    if (section === 'ENTITIES') {
      if (t.value === 'POLYLINE') {
        const got = fenDxfCollectPolyline(tokens, i, unresolved);
        entities.push(...got.entities);
        i = got.next;
        continue;
      }
      const rec = fenDxfRecord(tokens, i);
      if (rec.type === 'INSERT') {
        inserts.push({
          name: fenStr(rec.by, 2, ''),
          dx: fenNum(rec.by, 10), dy: fenNum(rec.by, 20),
          sx: fenNum(rec.by, 41, 1), sy: fenNum(rec.by, 42, 1),
          rot: fenNum(rec.by, 50, 0),
          cols: fenNum(rec.by, 70, 1), rows: fenNum(rec.by, 71, 1),
        });
      } else {
        entities.push(...fenDxfEntityFrom(rec, unresolved, annotationTypes));
      }
      i = rec.next;
      continue;
    }

    i += 1;
  }

  // Pass 2 — the header variables, read by name. Done separately because a
  // header variable's value can sit under several different codes and the name
  // is the only reliable anchor.
  for (let k = 0; k < tokens.length - 1; k++) {
    if (tokens[k].code !== 9) continue;
    const name = tokens[k].value;
    if (name === '$INSUNITS') { const n = parseInt(tokens[k + 1].value, 10); if (isFinite(n)) insunits = n; }
    if (name === '$EXTMIN') extMin = fenPt(parseFloat(tokens[k + 1].value), parseFloat((tokens[k + 2] || {}).value));
    if (name === '$EXTMAX') extMax = fenPt(parseFloat(tokens[k + 1].value), parseFloat((tokens[k + 2] || {}).value));
  }

  // Pass 3 — expand the inserts.
  inserts.forEach(ins => {
    const blk = blocks[ins.name];
    if (!blk) { unresolved.push({ type: 'INSERT', reason: `block "${ins.name}" is referenced but not defined in this file` }); return; }
    if (ins.cols > 1 || ins.rows > 1) {
      unresolved.push({ type: 'INSERT', reason: `block "${ins.name}" is an array (${ins.cols}×${ins.rows}) — arrays are not expanded` });
      return;
    }
    const t = { dx: ins.dx - blk.base.x * ins.sx, dy: ins.dy - blk.base.y * ins.sy, sx: ins.sx || 1, sy: ins.sy || 1, rot: ins.rot || 0 };
    blk.entities.forEach(e => {
      const moved = fenTransformEntity(e, t, unresolved);
      if (moved) entities.push(moved);
    });
  });

  const declared = insunits === null ? null : (FEN_DXF_UNITS[insunits] || { name: `Unknown code ${insunits}`, mm: null });
  const confidence = insunits === null ? 'Not declared'
    : (insunits === 0 ? 'Declared unitless' : 'Declared in the file');

  const annotations = [];
  annotationTypes.forEach(ty => {
    const found = annotations.find(a => a.type === ty);
    if (found) found.count += 1; else annotations.push({ type: ty, count: 1 });
  });

  return {
    ok: entities.length > 0,
    entities, warnings, unresolved, annotations,
    blockCount: Object.keys(blocks).length,
    insertCount: inserts.length,
    declaredUnits: declared ? declared.name : null,
    declaredUnitsMm: declared ? declared.mm : null,
    unitsConfidence: confidence,
    headerExtents: extMin && extMax ? { extMin, extMax } : null,
  };
}

// ═══════════════════════════════════════════════ SVG parser
// SVG is the other format a manufacturer's technical department will hand over,
// usually exported straight out of the same CAD. Two things have to be right or
// the geometry is subtly wrong rather than obviously broken:
//   · every ancestor's transform has to be composed down the tree, and
//   · SVG's y axis points DOWN while CAD's points up, so the whole thing is
//     flipped once at the end and the flip is recorded on the geometry.

function fenMatIdentity() { return [1, 0, 0, 1, 0, 0]; }
function fenMatMul(m, n) {
  // [a c e; b d f] · [a c e; b d f]
  return [
    m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
    m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
    m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5],
  ];
}
function fenMatApply(m, x, y) { return fenPt(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]); }

function fenParseTransform(str) {
  let m = fenMatIdentity();
  if (!str) return m;
  const re = /(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/g;
  let mt;
  while ((mt = re.exec(str))) {
    const args = mt[2].split(/[\s,]+/).filter(s => s !== '').map(parseFloat);
    switch (mt[1]) {
      case 'matrix': if (args.length === 6) m = fenMatMul(m, args); break;
      case 'translate': m = fenMatMul(m, [1, 0, 0, 1, args[0] || 0, args.length > 1 ? args[1] : 0]); break;
      case 'scale': {
        const sx = args[0] === undefined ? 1 : args[0];
        const sy = args[1] === undefined ? sx : args[1];
        m = fenMatMul(m, [sx, 0, 0, sy, 0, 0]);
        break;
      }
      case 'rotate': {
        const a = fenRad(args[0] || 0), c = Math.cos(a), s = Math.sin(a);
        if (args.length >= 3) {
          // rotate(a cx cy) is translate(cx cy) rotate(a) translate(-cx -cy).
          m = fenMatMul(m, [1, 0, 0, 1, args[1], args[2]]);
          m = fenMatMul(m, [c, s, -s, c, 0, 0]);
          m = fenMatMul(m, [1, 0, 0, 1, -args[1], -args[2]]);
        } else m = fenMatMul(m, [c, s, -s, c, 0, 0]);
        break;
      }
      case 'skewX': m = fenMatMul(m, [1, 0, Math.tan(fenRad(args[0] || 0)), 1, 0, 0]); break;
      case 'skewY': m = fenMatMul(m, [1, Math.tan(fenRad(args[0] || 0)), 0, 1, 0, 0]); break;
      default: break;
    }
  }
  return m;
}

// Path data, tokenised properly: commands can repeat their arguments without
// repeating the letter, coordinates can be separated by a comma, a space or
// nothing at all ("10-5" is two numbers), and a leading zero is optional.
function fenPathTokens(d) {
  const out = [];
  const re = /([MmLlHhVvCcSsQqTtAaZz])|(-?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?)/g;
  let m;
  while ((m = re.exec(String(d || '')))) {
    if (m[1]) out.push({ cmd: m[1] });
    else out.push({ num: parseFloat(m[2]) });
  }
  return out;
}

// Béziers are FLATTENED, and that is an approximation — so it is counted and
// reported on the geometry rather than passed off as exact.
const FEN_BEZIER_STEPS = 24;
function fenCubicPoints(p0, p1, p2, p3) {
  const out = [];
  for (let i = 1; i <= FEN_BEZIER_STEPS; i++) {
    const t = i / FEN_BEZIER_STEPS, u = 1 - t;
    out.push(fenPt(
      u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
      u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y));
  }
  return out;
}
function fenQuadPoints(p0, p1, p2) {
  const out = [];
  for (let i = 1; i <= FEN_BEZIER_STEPS; i++) {
    const t = i / FEN_BEZIER_STEPS, u = 1 - t;
    out.push(fenPt(u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
                   u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y));
  }
  return out;
}

// SVG states an arc by its endpoint; DXF states it by its centre. The
// conversion is the one in the SVG specification's own implementation notes —
// exact, not a fit — so a circular arc survives as a real ARC entity.
function fenSvgArcToEntity(p0, rx, ry, xRotDeg, largeArc, sweep, p1, unresolved) {
  if (!rx || !ry) return { points: [p1] };
  const phi = fenRad(xRotDeg || 0);
  const cosP = Math.cos(phi), sinP = Math.sin(phi);
  const dx2 = (p0.x - p1.x) / 2, dy2 = (p0.y - p1.y) / 2;
  const x1 = cosP * dx2 + sinP * dy2;
  const y1 = -sinP * dx2 + cosP * dy2;
  let RX = Math.abs(rx), RY = Math.abs(ry);
  const lam = (x1 * x1) / (RX * RX) + (y1 * y1) / (RY * RY);
  if (lam > 1) { const k = Math.sqrt(lam); RX *= k; RY *= k; }
  const den = RX * RX * y1 * y1 + RY * RY * x1 * x1;
  const num = RX * RX * RY * RY - den;
  let co = den === 0 ? 0 : Math.sqrt(Math.max(0, num / den));
  if (largeArc === sweep) co = -co;
  const cx1 = co * RX * y1 / RY;
  const cy1 = -co * RY * x1 / RX;
  const cx = cosP * cx1 - sinP * cy1 + (p0.x + p1.x) / 2;
  const cy = sinP * cx1 + cosP * cy1 + (p0.y + p1.y) / 2;
  const ang = (ux, uy, vx, vy) => {
    const dot = ux * vx + uy * vy;
    const len = Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy);
    let a = Math.acos(Math.max(-1, Math.min(1, len === 0 ? 1 : dot / len)));
    if (ux * vy - uy * vx < 0) a = -a;
    return a;
  };
  const theta1 = ang(1, 0, (x1 - cx1) / RX, (y1 - cy1) / RY);
  let dTheta = ang((x1 - cx1) / RX, (y1 - cy1) / RY, (-x1 - cx1) / RX, (-y1 - cy1) / RY);
  if (!sweep && dTheta > 0) dTheta -= FEN_TAU;
  if (sweep && dTheta < 0) dTheta += FEN_TAU;
  if (Math.abs(RX - RY) < 1e-6 && Math.abs(phi) < 1e-9) {
    // A true circular arc — stored as one, which is what a fabricator wants.
    const start = dTheta >= 0 ? theta1 : theta1 + dTheta;
    const end = dTheta >= 0 ? theta1 + dTheta : theta1;
    return { arc: { center: fenPt(cx, cy), radius: RX, startAngle: fenDeg(start), endAngle: fenDeg(end) } };
  }
  // A genuinely elliptical arc is kept as an ELLIPSE with its parameter range,
  // which is exactly what DXF's own ELLIPSE carries.
  return { ellipse: { center: fenPt(cx, cy),
    majorAxis: fenPt(RX * Math.cos(phi), RX * Math.sin(phi)), ratio: RY / RX,
    startParam: dTheta >= 0 ? theta1 : theta1 + dTheta,
    endParam: dTheta >= 0 ? theta1 + dTheta : theta1 } };
}

function fenPathToEntities(d, mat, stats, unresolved) {
  const toks = fenPathTokens(d);
  const ents = [];
  let cur = fenPt(0, 0), startPt = fenPt(0, 0);
  let run = [];
  let lastCtrl = null, lastCmd = '';
  const push = closed => {
    if (run.length > 1) {
      ents.push({ id: uid('fent'), type: 'POLYLINE', layer: 'svg',
        points: run.map(p => ({ ...fenMatApply(mat, p.x, p.y), bulge: 0 })), closed: !!closed, source: 'path' });
    }
    run = [];
  };
  let i = 0;
  let cmd = '';
  const nextNum = () => {
    const t = toks[i];
    if (!t || t.num === undefined) return null;
    i += 1;
    return t.num;
  };
  while (i < toks.length) {
    if (toks[i].cmd) { cmd = toks[i].cmd; i += 1; }
    else if (!cmd) { i += 1; continue; }                 // numbers before any command
    const rel = cmd === cmd.toLowerCase();
    const C = cmd.toUpperCase();

    if (C === 'Z') {
      if (run.length) { run.push(fenPt(startPt.x, startPt.y)); push(true); }
      cur = fenPt(startPt.x, startPt.y);
      lastCmd = C;
      continue;
    }
    if (C === 'M') {
      const x = nextNum(), y = nextNum();
      if (x === null || y === null) break;
      push(false);
      cur = rel ? fenPt(cur.x + x, cur.y + y) : fenPt(x, y);
      startPt = fenPt(cur.x, cur.y);
      run = [fenPt(cur.x, cur.y)];
      cmd = rel ? 'l' : 'L';                             // per the spec, an M's extra pairs are line-tos
      lastCmd = 'M';
      continue;
    }
    if (C === 'L' || C === 'H' || C === 'V') {
      let x, y;
      if (C === 'H') { const v = nextNum(); if (v === null) break; x = rel ? cur.x + v : v; y = cur.y; }
      else if (C === 'V') { const v = nextNum(); if (v === null) break; x = cur.x; y = rel ? cur.y + v : v; }
      else {
        const a = nextNum(), b = nextNum();
        if (a === null || b === null) break;
        x = rel ? cur.x + a : a; y = rel ? cur.y + b : b;
      }
      if (!run.length) run = [fenPt(cur.x, cur.y)];
      cur = fenPt(x, y); run.push(cur);
      lastCmd = C;
      continue;
    }
    if (C === 'C' || C === 'S') {
      let c1;
      if (C === 'S') {
        // The first control point of a smooth curve is the reflection of the
        // previous one — which is only defined after another curve.
        c1 = (lastCmd === 'C' || lastCmd === 'S') && lastCtrl
          ? fenPt(2 * cur.x - lastCtrl.x, 2 * cur.y - lastCtrl.y) : fenPt(cur.x, cur.y);
      } else {
        const a = nextNum(), b = nextNum();
        if (a === null || b === null) break;
        c1 = rel ? fenPt(cur.x + a, cur.y + b) : fenPt(a, b);
      }
      const c = nextNum(), dd = nextNum(), e = nextNum(), f = nextNum();
      if (c === null || dd === null || e === null || f === null) break;
      const c2 = rel ? fenPt(cur.x + c, cur.y + dd) : fenPt(c, dd);
      const end = rel ? fenPt(cur.x + e, cur.y + f) : fenPt(e, f);
      if (!run.length) run = [fenPt(cur.x, cur.y)];
      run.push(...fenCubicPoints(cur, c1, c2, end));
      stats.beziers += 1;
      cur = end; lastCtrl = c2; lastCmd = C;
      continue;
    }
    if (C === 'Q' || C === 'T') {
      let c1;
      if (C === 'T') {
        c1 = (lastCmd === 'Q' || lastCmd === 'T') && lastCtrl
          ? fenPt(2 * cur.x - lastCtrl.x, 2 * cur.y - lastCtrl.y) : fenPt(cur.x, cur.y);
      } else {
        const a = nextNum(), b = nextNum();
        if (a === null || b === null) break;
        c1 = rel ? fenPt(cur.x + a, cur.y + b) : fenPt(a, b);
      }
      const e = nextNum(), f = nextNum();
      if (e === null || f === null) break;
      const end = rel ? fenPt(cur.x + e, cur.y + f) : fenPt(e, f);
      if (!run.length) run = [fenPt(cur.x, cur.y)];
      run.push(...fenQuadPoints(cur, c1, end));
      stats.beziers += 1;
      cur = end; lastCtrl = c1; lastCmd = C;
      continue;
    }
    if (C === 'A') {
      const rx = nextNum(), ry = nextNum(), rot = nextNum();
      const laf = nextNum(), sf = nextNum(), x = nextNum(), y = nextNum();
      if (y === null) break;
      const end = rel ? fenPt(cur.x + x, cur.y + y) : fenPt(x, y);
      const got = fenSvgArcToEntity(cur, rx, ry, rot, !!laf, !!sf, end, unresolved);
      // An arc interrupts the polyline run: the run so far is emitted, the arc
      // is stored as an arc, and the run restarts at the arc's end.
      push(false);
      if (got.arc) {
        const c = fenMatApply(mat, got.arc.center.x, got.arc.center.y);
        const scale = Math.sqrt(Math.abs(mat[0] * mat[3] - mat[1] * mat[2])) || 1;
        ents.push({ id: uid('fent'), type: 'ARC', layer: 'svg', center: c, radius: got.arc.radius * scale,
          startAngle: got.arc.startAngle, endAngle: got.arc.endAngle });
        stats.arcs += 1;
      } else if (got.ellipse) {
        const c = fenMatApply(mat, got.ellipse.center.x, got.ellipse.center.y);
        ents.push({ id: uid('fent'), type: 'ELLIPSE', layer: 'svg', center: c,
          majorAxis: fenPt(mat[0] * got.ellipse.majorAxis.x + mat[2] * got.ellipse.majorAxis.y,
                           mat[1] * got.ellipse.majorAxis.x + mat[3] * got.ellipse.majorAxis.y),
          ratio: got.ellipse.ratio, startParam: got.ellipse.startParam, endParam: got.ellipse.endParam });
        stats.arcs += 1;
      }
      cur = end;
      run = [fenPt(cur.x, cur.y)];
      lastCmd = C;
      continue;
    }
    i += 1;                                              // a command this reader does not know
  }
  push(false);
  return ents;
}

function fenParseSvg(text) {
  const unresolved = [];
  const warnings = [];
  const stats = { beziers: 0, arcs: 0, rounded: 0 };
  let doc;
  try {
    doc = new DOMParser().parseFromString(String(text || ''), 'image/svg+xml');
  } catch (e) {
    return { ok: false, entities: [], warnings: ['This file could not be parsed as SVG.'], unresolved: [], annotations: [], declaredUnits: null, unitsConfidence: 'Unknown' };
  }
  const bad = doc.querySelector('parsererror');
  if (bad) {
    return { ok: false, entities: [], warnings: ['This file is not well-formed SVG — the browser refused it.'], unresolved: [], annotations: [], declaredUnits: null, unitsConfidence: 'Unknown' };
  }
  const root = doc.documentElement;
  const entities = [];
  const annotationTypes = [];

  function walk(node, mat) {
    for (let n = node.firstElementChild; n; n = n.nextElementSibling) {
      const tag = (n.tagName || '').toLowerCase();
      const m = fenMatMul(mat, fenParseTransform(n.getAttribute('transform')));
      const num = (a, d) => { const v = parseFloat(n.getAttribute(a)); return isFinite(v) ? v : (d || 0); };
      const P = (x, y) => fenMatApply(m, x, y);
      switch (tag) {
        case 'g': case 'svg': case 'a': case 'switch': walk(n, m); break;
        case 'defs': case 'style': case 'title': case 'desc': case 'metadata': break;
        case 'use':
          unresolved.push({ type: 'use', reason: 'symbol reference — the referenced shape is not expanded' });
          break;
        case 'line':
          entities.push({ id: uid('fent'), type: 'LINE', layer: 'svg',
            start: P(num('x1'), num('y1')), end: P(num('x2'), num('y2')) });
          break;
        case 'polyline': case 'polygon': {
          const pts = (n.getAttribute('points') || '').trim().split(/[\s,]+/).map(parseFloat);
          const out = [];
          for (let k = 0; k + 1 < pts.length; k += 2) out.push({ ...P(pts[k], pts[k + 1]), bulge: 0 });
          if (out.length > 1) entities.push({ id: uid('fent'), type: 'POLYLINE', layer: 'svg', points: out, closed: tag === 'polygon', source: tag });
          break;
        }
        case 'rect': {
          const x = num('x'), y = num('y'), w = num('width'), h = num('height');
          if (n.getAttribute('rx') || n.getAttribute('ry')) stats.rounded += 1;
          entities.push({ id: uid('fent'), type: 'POLYLINE', layer: 'svg', closed: true, source: 'rect',
            points: [P(x, y), P(x + w, y), P(x + w, y + h), P(x, y + h)].map(p => ({ ...p, bulge: 0 })) });
          break;
        }
        case 'circle':
          entities.push({ id: uid('fent'), type: 'CIRCLE', layer: 'svg',
            center: P(num('cx'), num('cy')),
            radius: num('r') * (Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || 1) });
          break;
        case 'ellipse': {
          const rx = num('rx'), ry = num('ry');
          entities.push({ id: uid('fent'), type: 'ELLIPSE', layer: 'svg',
            center: P(num('cx'), num('cy')),
            majorAxis: fenPt(m[0] * rx, m[1] * rx),
            ratio: rx === 0 ? 1 : ry / rx, startParam: 0, endParam: FEN_TAU });
          break;
        }
        case 'path':
          entities.push(...fenPathToEntities(n.getAttribute('d'), m, stats, unresolved));
          break;
        case 'text': case 'tspan': annotationTypes.push('text'); break;
        case 'image':
          // The one case worth being loud about: an SVG that is only a wrapper
          // around a raster image has no geometry in it at all, and is exactly
          // the "picture of a profile" the client ruled out.
          unresolved.push({ type: 'image', reason: 'this SVG embeds a bitmap — a picture of a section is not a section' });
          break;
        default:
          unresolved.push({ type: tag, reason: 'element not read as geometry' });
          break;
      }
    }
  }
  walk(root, fenMatIdentity());

  // The Y flip, applied once, to the whole drawing. Without it every section is
  // upside down and every "interior side" label is wrong.
  const flipped = entities.map(ent => {
    switch (ent.type) {
      case 'LINE': return { ...ent, start: fenPt(ent.start.x, -ent.start.y), end: fenPt(ent.end.x, -ent.end.y) };
      case 'POLYLINE': return { ...ent, points: ent.points.map(p => ({ x: p.x, y: -p.y, bulge: -(p.bulge || 0) })) };
      case 'ARC': return { ...ent, center: fenPt(ent.center.x, -ent.center.y), startAngle: -ent.endAngle, endAngle: -ent.startAngle };
      case 'CIRCLE': return { ...ent, center: fenPt(ent.center.x, -ent.center.y) };
      case 'ELLIPSE': return { ...ent, center: fenPt(ent.center.x, -ent.center.y), majorAxis: fenPt(ent.majorAxis.x, -ent.majorAxis.y) };
      default: return ent;
    }
  });

  if (stats.beziers) warnings.push(`${stats.beziers} Bézier curve${stats.beziers === 1 ? '' : 's'} flattened to ${FEN_BEZIER_STEPS}-segment polylines — an approximation, recorded on the profile.`);
  if (stats.rounded) warnings.push(`${stats.rounded} rounded rectangle${stats.rounded === 1 ? '' : 's'} read as square corners — the corner radius was not applied.`);

  // Units. An SVG that states its width in mm is telling the truth about scale;
  // one that gives bare user units is not, and that difference is carried
  // through as confidence rather than hidden.
  const wAttr = root.getAttribute('width') || '';
  const unitMatch = wAttr.match(/(mm|cm|in|pt|pc)\s*$/i);
  const declared = unitMatch ? unitMatch[1].toLowerCase() : null;
  const confidence = declared ? 'Declared in the file' : 'Not declared';

  const annotations = [];
  annotationTypes.forEach(ty => {
    const found = annotations.find(a => a.type === ty);
    if (found) found.count += 1; else annotations.push({ type: ty, count: 1 });
  });

  return {
    ok: flipped.length > 0, entities: flipped, warnings, unresolved, annotations,
    declaredUnits: declared === 'mm' ? 'Millimetres' : declared === 'cm' ? 'Centimetres'
      : declared === 'in' ? 'Inches' : declared ? declared : null,
    declaredUnitsMm: declared === 'mm' ? 1 : declared === 'cm' ? 10 : declared === 'in' ? 25.4 : null,
    unitsConfidence: confidence,
    flattenedCurves: stats.beziers,
  };
}

// ═══════════════════════════════════════════════ Units, validation, assembly
// Nothing here decides anything on its own. It measures, it compares against
// what a person typed off the manufacturer's datasheet, and it raises a
// question. Confirmation is always a human act.

function fenSuggestUnits(rawBounds, declaredMm) {
  // A declaration in the file wins — it is the manufacturer's own statement.
  if (declaredMm) {
    const key = FEN_UNIT_CHOICES.find(u => Math.abs(u.mm - declaredMm) < 1e-9);
    if (key) return { units: key.key, confidence: 'High', why: 'the file declares its units' };
  }
  const w = Math.max(rawBounds.width, rawBounds.depth);
  if (!(w > 0)) return { units: 'mm', confidence: 'None', why: 'nothing measurable was read' };
  // The window profile itself is the ruler: an extrusion is tens of millimetres,
  // a couple of inches, a few centimetres. Each of these is a SUGGESTION with
  // its reason attached, and every one of them still has to be confirmed.
  if (w >= 20 && w <= 600) return { units: 'mm', confidence: 'Medium', why: `${Math.round(w)} units reads as millimetres for a window profile` };
  // Between roughly 1 and 20 units the number is genuinely ambiguous — 3.4 is a
  // plausible inch figure AND a plausible centimetre one for the same 86 mm
  // profile. Naming one and calling it Low confidence would hide that, so the
  // suggestion names both and sends the reader to the datasheet.
  if (w >= 0.8 && w < 20) {
    return { units: 'in', confidence: 'Low',
      why: `${w.toFixed(2)} units is ${(w * 25.4).toFixed(0)} mm read as inches or ${(w * 10).toFixed(0)} mm read as centimetres — both are plausible, so check the datasheet` };
  }
  if (w > 600) return { units: 'mm', confidence: 'None', why: `${Math.round(w)} units is far too large for a cross-section — this is probably a unit or scale error` };
  return { units: 'mm', confidence: 'None', why: 'the size does not resemble a window profile at any of these units' };
}

function fenValidateGeometry(geo, system) {
  const issues = [];
  const b = geo.bounds;
  const add = (level, msg) => issues.push({ level, msg });

  if (!geo.entities.length) add('error', 'No geometry was read from this file.');
  if (geo.entities.length && !(b.width > 0 && b.depth > 0)) add('error', 'The geometry has no area — every point is on one line.');

  if (b.width > FEN_PLAUSIBLE_MAX_MM || b.depth > FEN_PLAUSIBLE_MAX_MM) {
    add('error', `This computes to ${Math.round(b.width)} × ${Math.round(b.depth)} mm. A window profile is tens of millimetres across, so this is almost certainly a unit or scale error — check the units before approving.`);
  } else if ((b.width && b.width < FEN_PLAUSIBLE_MIN_MM) || (b.depth && b.depth < FEN_PLAUSIBLE_MIN_MM)) {
    add('error', `This computes to ${b.width.toFixed(1)} × ${b.depth.toFixed(1)} mm, which is smaller than any real extrusion — check the units.`);
  }

  if (!geo.unitsConfirmedBy) add('warn', 'Units have not been confirmed by anyone yet. Nothing is activated until they are.');

  const outer = (geo.closedLoops || []).filter(l => l.kind === 'Outer boundary');
  if (!outer.length) add('warn', 'No closed outer boundary was found. A cross-section has to close, so this is either an open drawing or a section cut on the wrong layer.');
  const chambers = (geo.closedLoops || []).filter(l => l.kind === 'Chamber').length;
  if (outer.length && !chambers) add('info', 'One closed loop and no internal chambers. Correct for a solid section; worth a second look on a multi-chamber PVC system.');

  if (geo.unresolved && geo.unresolved.length) {
    add('warn', `${geo.unresolved.length} entit${geo.unresolved.length === 1 ? 'y was' : 'ies were'} read but not resolved into geometry — they are listed on the profile rather than dropped.`);
  }
  (geo.features || []).forEach(f => { if (f.kind === 'approximation') add('warn', f.note); });
  if (geo.openChainCount) add('warn', `${geo.openChainCount} run${geo.openChainCount === 1 ? '' : 's'} of lines did not close up. Small gaps in an export are common; a large one means the section is incomplete.`);

  const hasError = issues.some(i => i.level === 'error');
  const hasWarn = issues.some(i => i.level === 'warn');
  const status = !geo.entities.length ? FEN_GEO_INVALID
    : hasError ? FEN_GEO_REVIEW
    : hasWarn ? FEN_GEO_WARN
    : FEN_GEO_VALID;
  return { status, issues, checkedDate: todayISO() };
}

// Turn a parse result plus a confirmed unit into a stored geometry record.
function fenBuildGeometry(parse, opts) {
  const factor = fenUnitFactor(opts.units) || 1;
  const scaled = fenScaleEntities(parse.entities, factor);
  const bounds = fenComputeBounds(scaled);
  const loops = fenDetectClosedLoops(scaled);
  const outer = loops.loops.find(l => l.kind === 'Outer boundary');
  const centroid = outer ? fenCentroidOf(outer.points) : fenCentroidOf(scaled.flatMap(fenEntityPoints));
  const features = [];
  if (parse.flattenedCurves) {
    features.push({ kind: 'approximation', note: `${parse.flattenedCurves} Bézier curve${parse.flattenedCurves === 1 ? '' : 's'} were flattened to polylines on import — the stored coordinates are an approximation of those curves.` });
  }
  const geo = makeProfileGeometry({
    entities: scaled,
    sourceUnits: parse.declaredUnits,
    sourceUnitsConfidence: parse.unitsConfidence,
    unitsUsed: opts.units,
    unitsConfirmedBy: opts.confirmedBy || null,
    unitsConfirmedDate: opts.confirmedBy ? todayISO() : null,
    sourceFile: opts.fileName || '',
    sourceFormat: opts.format || '',
    sourceHash: opts.hash || '',
    sourceBytes: opts.bytes || 0,
    sourceText: opts.text || null,
    sourceDataUrl: opts.dataUrl || null,
    importedBy: opts.confirmedBy || '',
    importedDate: todayISO(),
    bounds,
    centroid: { x: centroid.x, y: centroid.y },
    origin: { x: bounds.minX, y: bounds.minY },
    orientation: opts.orientation || { interiorDir: '-Y', exteriorDir: '+Y', glazingDir: '+X' },
    closedLoops: loops.loops.map(l => ({ index: l.index, kind: l.kind, area: l.area, entityIds: l.entityIds, points: l.points })),
    openChainCount: loops.openChains.length,
    features,
    unresolved: parse.unresolved || [],
    annotations: parse.annotations || [],
  });
  geo.validation = fenValidateGeometry(geo);
  return geo;
}

// ═══════════════════════════════════════════════ Hand entry
// A person typing coordinates off a dimensioned section produces EXACTLY the
// record the importer produces — the same entity shapes, the same loop
// detection, the same bounds and centroid, the same validation — because it
// goes through fenBuildGeometry like everything else. Only the provenance
// differs, and provenance is a field, not a second data shape. That is what
// keeps the assembly designer, the sections, the BOM and the cut list working
// unchanged against a profile nobody parsed.

function makeFenManualLoop(data) {
  return { id: uid('floop'), kind: 'Outer boundary', points: [], ...data };
}

// One closed POLYLINE per authored loop, in millimetres. Every point carries
// `bulge: 0`: hand entry is straight segments, and a curve nobody typed is a
// curve nobody should be shown. The entity id is derived from the loop id so
// the loop kinds a person set can be matched back after detection.
function fenManualEntities(loops) {
  return (loops || []).filter(l => (l.points || []).length >= 3).map(l => ({
    id: `fent-manual-${l.id}`, type: 'POLYLINE', layer: 'manual', source: 'manual', closed: true,
    points: l.points.map(p => ({ x: Number(p.x) || 0, y: Number(p.y) || 0, bulge: 0 })),
  }));
}

// The same shape the two parsers return, so fenBuildGeometry cannot tell the
// difference and there is only ever one way into a geometry record.
function fenManualParse(loops) {
  const entities = fenManualEntities(loops);
  return {
    ok: entities.length > 0, entities, warnings: [], unresolved: [], annotations: [],
    declaredUnits: 'Millimetres', declaredUnitsMm: 1,
    unitsConfidence: 'Typed in millimetres by hand',
  };
}

function fenBuildManualGeometry(loops, opts) {
  const o = opts || {};
  const parse = fenManualParse(loops);
  // The authored coordinates ARE the source here, so they are what gets kept
  // and fingerprinted — the same promise the importer makes about a file.
  const authored = JSON.stringify({
    loops: (loops || []).map(l => ({ kind: l.kind, points: l.points })),
    sourceDocument: o.sourceDocName || '',
  });
  const geo = fenBuildGeometry(parse, {
    units: 'mm',
    fileName: o.sourceDocName || 'typed by hand',
    format: 'manual',
    hash: fenHash(authored),
    bytes: authored.length,
    text: authored,
    confirmedBy: o.by || '',
    orientation: o.orientation,
  });
  // The detector's largest-area rule is a good default for a file nobody
  // annotated. It is not better than the answer of the person who typed it, so
  // the person's answer wins.
  const said = {};
  (loops || []).forEach(l => { said[`fent-manual-${l.id}`] = l.kind; });
  geo.closedLoops = (geo.closedLoops || []).map(loop => {
    const kind = (loop.entityIds || []).map(id => said[id]).find(k => k);
    return kind ? { ...loop, kind } : loop;
  });
  geo.manualLoops = (loops || []).map(l => ({
    id: l.id, kind: l.kind, points: (l.points || []).map(p => ({ x: Number(p.x) || 0, y: Number(p.y) || 0 })),
  }));
  geo.enteredByHand = true;
  geo.tracedFromImage = !!o.tracedFromImage;
  geo.validation = fenValidateGeometry(geo);
  return geo;
}

// Reopening a profile in the editor. A hand-entered one restores exactly what
// was typed; a parsed one is offered as its detected loops, which is what the
// coordinate table can actually edit — and the editor says out loud that a
// manufacturer's arc comes back as the points it was sampled at.
function fenDedupePoints(points) {
  const out = [];
  (points || []).forEach(p => {
    const last = out[out.length - 1];
    const x = Number(p.x) || 0, y = Number(p.y) || 0;
    if (last && Math.abs(last.x - x) < 1e-6 && Math.abs(last.y - y) < 1e-6) return;
    out.push({ x, y });
  });
  if (out.length > 1) {
    const a = out[0], b = out[out.length - 1];
    if (Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6) out.pop();
  }
  return out;
}
function fenLoopsFromGeometry(geo) {
  if (!geo) return [];
  if (geo.manualLoops && geo.manualLoops.length) {
    return geo.manualLoops.map(l => makeFenManualLoop({
      kind: l.kind === 'Chamber' ? 'Chamber' : 'Outer boundary',
      points: (l.points || []).map(p => ({ x: p.x, y: p.y })),
    }));
  }
  return (geo.closedLoops || []).map(l => makeFenManualLoop({
    kind: l.kind === 'Chamber' ? 'Chamber' : 'Outer boundary',
    points: fenDedupePoints(l.points),
  }));
}
function fenGeometryHasCurves(geo) {
  return !!(geo && (geo.entities || []).some(e => e.type === 'ARC' || e.type === 'CIRCLE' || e.type === 'ELLIPSE'
    || (e.type === 'POLYLINE' && (e.points || []).some(p => p.bulge))));
}

// Pasting points. Somebody copying a coordinate list out of a PDF or a
// spreadsheet gets whitespace, commas or tabs, and often a row-number column
// they did not ask for — so all three are read, and the index column is dropped
// only when EVERY line has one and it genuinely counts. Guessing it on a mixed
// paste would eat a real X value.
const FEN_NUM_RE = /-?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][-+]?\d+)?/g;
function fenParsePointText(text) {
  const lines = String(text || '').split(/\r\n|\r|\n/)
    .map(s => s.trim()).filter(s => s !== '' && !/^(#|\/\/)/.test(s));
  const perLine = lines.map(l => (l.match(FEN_NUM_RE) || []).map(Number));
  const errors = [];
  let drop = 0;
  if (perLine.length > 1 && perLine.every(n => n.length === 3)) {
    const first = perLine.map(n => n[0]);
    const startsAt = first[0];
    if ((startsAt === 0 || startsAt === 1) && first.every((v, i) => v === startsAt + i)) drop = 1;
  }
  const points = [];
  perLine.forEach((nums, i) => {
    const vals = nums.slice(drop);
    if (vals.length < 2) { errors.push(`Line ${i + 1}: fewer than two numbers — skipped.`); return; }
    if (vals.length % 2) errors.push(`Line ${i + 1}: ${vals.length} numbers — a point is two, so the last one was ignored.`);
    const usable = vals.length - (vals.length % 2);
    for (let k = 0; k + 1 < usable; k += 2) points.push({ x: vals[k], y: vals[k + 1] });
  });
  return { points, errors, droppedIndexColumn: !!drop };
}
function fenPointsToText(points) {
  return (points || []).map(p => `${Number(p.x) || 0}, ${Number(p.y) || 0}`).join('\n');
}

// ── Hand-entry validation ─────────────────────────────────────────────────
// Clear errors rather than silent rejection: every rule says which loop, which
// points, and what would have to be true instead.
function fenSegmentsIntersect(a, b, c, d) {
  const o = (p, q, r) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
  const on = (p, q, r) => Math.abs(o(p, q, r)) < 1e-9
    && Math.min(p.x, q.x) - 1e-9 <= r.x && r.x <= Math.max(p.x, q.x) + 1e-9
    && Math.min(p.y, q.y) - 1e-9 <= r.y && r.y <= Math.max(p.y, q.y) + 1e-9;
  const d1 = o(a, b, c), d2 = o(a, b, d), d3 = o(c, d, a), d4 = o(c, d, b);
  if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true;
  return on(a, b, c) || on(a, b, d) || on(c, d, a) || on(c, d, b);
}
// Adjacent segments share an endpoint by definition, so they are skipped —
// otherwise every well-formed loop reports itself as self-crossing.
function fenLoopSelfIntersection(points) {
  const n = points.length;
  for (let i = 0; i < n; i++) {
    const a = points[i], b = points[(i + 1) % n];
    for (let j = i + 1; j < n; j++) {
      if (j === (i + 1) % n || (j + 1) % n === i) continue;
      const c = points[j], d = points[(j + 1) % n];
      if (fenSegmentsIntersect(a, b, c, d)) return { i, j };
    }
  }
  return null;
}
function fenPointInPolygon(pt, poly) {
  let inside = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
    const dy = (yj - yi) || 1e-12;
    if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / dy + xi)) inside = !inside;
  }
  return inside;
}

function fenValidateManualLoops(loops, fields) {
  const issues = [];
  const add = (level, msg) => issues.push({ level, msg });
  const list = loops || [];
  const f = fields || {};

  if (!list.length) add('error', 'There is no geometry yet. Type the boundary’s points into the coordinate table, or draw them on the canvas.');

  const outers = list.filter(l => l.kind === 'Outer boundary');
  if (list.length && !outers.length) add('error', 'No loop is marked as the outer boundary. A cross-section has exactly one, and everything downstream measures the section from it.');
  if (outers.length > 1) add('error', `${outers.length} loops are marked as the outer boundary. Only one can be — the others are chambers or voids.`);

  list.forEach((l, idx) => {
    const label = `${l.kind} ${idx + 1}`;
    const raw = l.points || [];
    // A blank cell is not a zero. Number('') is 0, which would silently move a
    // point to the origin, so the raw value is tested before it is converted.
    const blank = raw.findIndex(p => ['x', 'y'].some(k => p[k] === '' || p[k] === null || p[k] === undefined));
    if (blank >= 0) { add('error', `${label}: point ${blank + 1} has an empty coordinate. Fill it in or delete the point — an empty cell is not a zero.`); return; }
    const pts = raw.map(p => ({ x: Number(p.x), y: Number(p.y) }));
    const nan = pts.findIndex(p => !isFinite(p.x) || !isFinite(p.y));
    if (nan >= 0) { add('error', `${label}: point ${nan + 1} is not a number.`); return; }
    if (pts.length < 3) { add('error', `${label}: ${pts.length} point${pts.length === 1 ? '' : 's'}. A closed loop needs at least three.`); return; }
    if (Math.abs(fenPolygonArea(pts)) < 1e-6) { add('error', `${label}: encloses no area — every point falls on one line, so it never closes into a shape.`); return; }
    const hit = fenLoopSelfIntersection(pts);
    if (hit) add('error', `${label}: the outline crosses itself between point ${hit.i + 1} and point ${hit.j + 1}. Reorder those points — a section boundary cannot cross itself.`);
  });

  const outer = outers[0];
  if (outer && (outer.points || []).length >= 3) {
    const b = fenComputeBounds(fenManualEntities([outer]));
    if (!(b.width > 0) || !(b.depth > 0)) add('error', 'The outer boundary has no width or no depth.');
    else if (b.width > FEN_PLAUSIBLE_MAX_MM || b.depth > FEN_PLAUSIBLE_MAX_MM) {
      add('error', `This measures ${Math.round(b.width)} × ${Math.round(b.depth)} mm. Coordinates here are millimetres and a window extrusion is tens of millimetres across, so these numbers are almost certainly in the wrong unit.`);
    } else if (b.width < FEN_PLAUSIBLE_MIN_MM || b.depth < FEN_PLAUSIBLE_MIN_MM) {
      add('warn', `This measures ${b.width.toFixed(1)} × ${b.depth.toFixed(1)} mm, smaller than any real extrusion. Check the coordinates are millimetres and not centimetres or inches.`);
    }
    list.filter(l => l.kind === 'Chamber').forEach((l, i) => {
      const p = (l.points || [])[0];
      if (p && !fenPointInPolygon({ x: Number(p.x), y: Number(p.y) }, outer.points)) {
        add('warn', `Chamber ${i + 1} starts outside the outer boundary. A chamber is a void inside the section, so either the loop kind is wrong or a point is astray.`);
      }
    });
  }

  if (!String(f.code || '').trim()) add('error', 'Give the profile the manufacturer’s own code. Everything downstream refers to it by that.');
  [['wallThicknessMm', 'Wall thickness'], ['glazingPocketMm', 'Glazing pocket'], ['weightPerMetreKg', 'Weight per metre']]
    .forEach(([k, label]) => {
      const v = f[k];
      if (v === null || v === undefined || v === '') return;
      if (!isFinite(Number(v)) || Number(v) <= 0) add('error', `${label} has to be a positive number, or be left blank.`);
    });
  // "Entered from dimensioned drawing" is a claim about a specific document.
  // A record making that claim without naming one is the failure mode this
  // whole field exists to prevent.
  if (f.provenance === FEN_PROV_DRAWING && !String(f.sourceDocName || '').trim()) {
    add('error', 'Name the manufacturer document these dimensions were read off — the drawing number, the datasheet page, the revision. Without it the record claims a source it cannot show.');
  }
  return issues;
}

// ── The assembly engine ───────────────────────────────────────────────────
// Rows divided by transoms, bays within a row divided by mullions. A bay is
// either an equal share of what is left or an exact number, and a locked bay
// keeps its number when the rest are re-divided.
function fenDivide(total, items, gap) {
  // items: [{ mode, size }] — returns sizes, or an issue when it does not fit.
  const gaps = gap * Math.max(0, items.length - 1);
  const fixed = items.filter(i => i.mode === 'exact').reduce((a, i) => a + qnum(i.size), 0);
  const autos = items.filter(i => i.mode !== 'exact').length;
  const left = total - gaps - fixed;
  const each = autos ? left / autos : 0;
  const sizes = items.map(i => (i.mode === 'exact' ? qnum(i.size) : each));
  const issue = left < 0 ? `The exact sizes and members add up to ${Math.round(-left)} mm more than the opening.`
    : (autos === 0 && Math.abs(left) > 0.5) ? `Every division is set exactly and they leave ${Math.round(left)} mm unaccounted for.`
    : (autos && each <= 0) ? 'There is nothing left for the divisions set to share equally.'
    : null;
  return { sizes, issue, each };
}

function fenComputeAssembly(asm) {
  const issues = [];
  const W = qnum(asm.width), H = qnum(asm.height);
  const ff = qnum(asm.frameFaceWidth);
  const mf = qnum(asm.mullionFaceWidth);
  const tf = qnum(asm.transomFaceWidth);
  if (!(W > 0) || !(H > 0)) issues.push({ level: 'error', msg: 'Enter an overall width and height.' });

  const innerX = ff, innerY = ff;
  const innerW = W - ff * 2, innerH = H - ff * 2;
  if (innerW <= 0 || innerH <= 0) issues.push({ level: 'error', msg: 'The frame face widths are larger than the opening itself.' });

  const rows = asm.rows || [];
  const rowDiv = fenDivide(innerH, rows.map(r => ({ mode: r.heightMode, size: r.height })), tf);
  if (rowDiv.issue) issues.push({ level: 'error', msg: `Rows: ${rowDiv.issue}` });

  let y = innerY;
  const outRows = rows.map((r, ri) => {
    const h = rowDiv.sizes[ri];
    const bayDiv = fenDivide(innerW, (r.bays || []).map(b => ({ mode: b.widthMode, size: b.width })), mf);
    if (bayDiv.issue) issues.push({ level: 'error', msg: `Row ${ri + 1}: ${bayDiv.issue}` });
    let x = innerX;
    const bays = (r.bays || []).map((b, bi) => {
      const w = bayDiv.sizes[bi];
      const cell = { id: b.id, bay: b, x, y, w, h, row: ri, col: bi };
      x += w + mf;
      return cell;
    });
    const out = { id: r.id, row: r, y, h, bays, index: ri };
    y += h + tf;
    return out;
  });

  const allBays = outRows.flatMap(r => r.bays);
  const ro = {
    w: W + qnum(asm.shimJambEach) * 2,
    h: H + qnum(asm.shimHead) + qnum(asm.shimSill),
  };
  return { W, H, frameFace: ff, mullionFace: mf, transomFace: tf,
           innerX, innerY, innerW, innerH, rows: outRows, bays: allBays, ro, issues };
}

// The instance list an assembly REQUIRES, derived from its own grid. Each has a
// stable key, so re-deriving after a size change keeps the profile a person
// assigned to that position instead of resetting it.
function fenRequiredInstances(asm, computed) {
  const list = [];
  const joint = asm.cornerJoint || 'Not set';
  const ff = computed.frameFace;
  const mitred = joint === 'Mitre 45°' || joint === 'Welded';
  // A mitred corner runs each member to the outside dimension; a butt joint runs
  // head and sill full width and shortens the jambs by a face at each end. That
  // is arithmetic on the assembly, but it still needs the FACE WIDTH, which is
  // only known once a profile with real geometry is assigned — hence the nulls.
  const known = ff > 0;
  const cut = (full, deduct) => (known ? full - deduct : null);

  list.push(makeFenProfileInstance({ key: 'Head', role: 'Head', x: 0, y: computed.H - ff, rotation: 180,
    cutLength: mitred ? computed.W : cut(computed.W, 0), jointType: joint }));
  list.push(makeFenProfileInstance({ key: 'Sill', role: 'Sill', x: 0, y: 0, rotation: 0,
    cutLength: mitred ? computed.W : cut(computed.W, 0), jointType: joint }));
  list.push(makeFenProfileInstance({ key: 'Left Jamb', role: 'Left Jamb', x: 0, y: 0, rotation: 90,
    cutLength: mitred ? computed.H : cut(computed.H, ff * 2), jointType: joint }));
  list.push(makeFenProfileInstance({ key: 'Right Jamb', role: 'Right Jamb', x: computed.W - ff, y: 0, rotation: 90,
    mirrorX: true, cutLength: mitred ? computed.H : cut(computed.H, ff * 2), jointType: joint }));

  computed.rows.forEach((r, ri) => {
    if (ri > 0) {
      list.push(makeFenProfileInstance({ key: `Transom ${ri}`, role: 'Transom', x: computed.innerX, y: r.y - computed.transomFace,
        rotation: 0, cutLength: known ? computed.innerW : null, jointType: 'Butt / mechanical' }));
    }
    r.bays.forEach((cell, bi) => {
      if (bi > 0) {
        list.push(makeFenProfileInstance({ key: `Mullion R${ri + 1}-${bi}`, role: 'Mullion',
          x: cell.x - computed.mullionFace, y: r.y, rotation: 90,
          cutLength: known ? r.h : null, jointType: 'Butt / mechanical' }));
      }
      if (FEN_OPERABLE(cell.bay.operation)) {
        // A sash is four cuts of one extrusion, which is exactly the point of
        // holding placements rather than drawings.
        list.push(makeFenProfileInstance({ key: `Sash R${ri + 1}-${bi + 1}`, role: 'Sash',
          x: cell.x, y: cell.y, rotation: 0, qty: 2,
          cutLength: null, jointType: 'Mitre 45°',
          notes: `${cell.bay.operation} — 2 cuts at the bay width and 2 at its height, less the sash overlap from the profile section` }));
      }
      if (cell.bay.operation !== 'Spandrel') {
        list.push(makeFenProfileInstance({ key: `Bead R${ri + 1}-${bi + 1}`, role: 'Glazing Bead',
          x: cell.x, y: cell.y, rotation: 0, qty: 4, cutLength: null, jointType: 'Mitre 45°' }));
      }
    });
  });
  return list;
}

function fenOperationSummary(asm) {
  const counts = {};
  (asm.rows || []).forEach(r => (r.bays || []).forEach(b => { counts[b.operation] = (counts[b.operation] || 0) + 1; }));
  const keys = Object.keys(counts);
  if (!keys.length) return '—';
  return keys.map(k => `${k}${counts[k] > 1 ? ` ×${counts[k]}` : ''}`).join(', ');
}

// Type first, then the instance's own overrides — the same resolution doors.jsx
// uses, so "why is this one different" is answered the same way in both.
function fenResolveInstance(project, inst) {
  const types = (project && project.fenestrationTypes) || [];
  const type = types.find(t => t.id === inst.typeId) || null;
  const asm = type ? cloneDeep(type.assembly) : makeFenAssembly();
  const own = [];
  const ov = inst.overrides || {};
  ['width', 'height', 'glass', 'finish', 'sillHeight', 'frameFaceWidth', 'mullionFaceWidth',
   'transomFaceWidth', 'cornerJoint', 'shimJambEach', 'shimHead', 'shimSill'].forEach(k => {
    if (ov[k] !== null && ov[k] !== undefined && ov[k] !== '') { asm[k] = ov[k]; own.push(k); }
  });
  const computed = fenComputeAssembly(asm);
  return { type, assembly: asm, computed, ownFields: own };
}

// ═══════════════════════════════════════════════ Shared UI pieces
// A dimension field that speaks the user's units both ways. Profile geometry is
// always shown in millimetres regardless of this setting, because that is the
// unit every manufacturer publishes a section in.
function FenDimField({ value, onChange, system, placeholder, w, disabled }) {
  const [draft, setDraft] = useState(null);
  const shown = draft !== null ? draft
    : (value === null || value === undefined || value === '' ? '' : fmtDim(value, system, { inchesOnly: true }));
  return (
    <input type="text" value={shown} placeholder={placeholder || '—'} disabled={disabled}
      onChange={e => setDraft(e.target.value)}
      onBlur={e => {
        setDraft(null);
        const t = e.target.value.trim();
        if (!t) { onChange(null); return; }
        const mm = parseDim(t, system);
        if (mm !== null) onChange(mm);
      }}
      className={`${w || 'w-24'} px-2 py-1 text-sm border border-[var(--leon-line)] rounded bg-white focus:outline-none focus:border-[var(--leon-brown)] disabled:bg-[var(--leon-cream)]`} />
  );
}

// One plain line, in the place a person would look for the thing that is not
// there. Not an error, not a placeholder drawing — a sentence.
function FenGap({ title, children, what }) {
  return (
    <div className="rounded-lg border border-dashed border-[var(--leon-line)] bg-[var(--leon-cream)]/50 p-5">
      <div className="font-bold text-sm mb-1">{title}</div>
      <p className="text-sm text-[var(--leon-black)]/65 max-w-3xl">{children}</p>
      {what && (
        <p className="text-xs text-[var(--leon-black)]/50 mt-2 max-w-3xl">
          <b>What to supply:</b> {what}
        </p>
      )}
    </div>
  );
}

const FEN_SUPPLY_LINE = 'the manufacturer’s own cross-section CAD — a DXF (ASCII) or an SVG per profile, or a ZIP of them to unpack and drop in. A PDF page, a datasheet image or a screenshot is not geometry and will not be accepted.';

function FenIssueList({ issues }) {
  if (!issues || !issues.length) return null;
  const tone = l => l === 'error' ? 'bg-red-50 border-red-200 text-red-700'
    : l === 'warn' ? 'bg-amber-50 border-amber-200 text-amber-900'
    : 'bg-[var(--leon-cream)] border-[var(--leon-line)] text-[var(--leon-black)]/70';
  return (
    <div className="space-y-1.5">
      {issues.map((i, k) => <div key={k} className={`rounded border text-xs px-2.5 py-1.5 ${tone(i.level)}`}>{i.msg}</div>)}
    </div>
  );
}

// The session warning. It appears wherever a profile could be lost, and it says
// exactly what has to happen for that to stop being true.
//
// COORDINATOR: hand-entered profiles are lost on reload for the same reason
// imported ones are, and losing an hour of typed coordinates is worse than
// losing a re-runnable import — so this is now the more urgent half of the same
// missing key. See the COORDINATOR note above fenDefaultLibrary(): add
// `fenestrationLibrary` to App()'s state, to the savePersistedState object AND
// its dependency array, and onto ctx, then point fenLibrary()/fenLibraryUpdate()
// at ctx. Nothing else in this module has to change.
function FenSessionNotice() {
  return (
    <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
      <b>Profiles last until this page is reloaded.</b> The profile library is global, and this prototype
      has no persisted key for it yet — one belongs in the app&rsquo;s saved state and has to be added
      there, not from inside this module. Until it is: re-import after a refresh, keep the original
      manufacturer files as the record, and if you have typed a section by hand, export or copy its
      coordinates out of the editor before you leave the page.
    </div>
  );
}

// ── Provenance on screen ──────────────────────────────────────────────────
// The tag goes wherever a profile is named. It is deliberately small and
// deliberately everywhere: a hand-entered section that appears untagged
// somewhere is a hand-entered section passing as verified CAD there.
function FenProvenanceTag({ profile, provenance, full, title }) {
  const p = provenance || fenProvenanceOf(profile);
  return (
    <span title={title === false ? undefined : (title || FEN_PROV_MEANING[p] || p)}>
      <Badge tone={FEN_PROV_TONE[p] || 'yellow'}>{full ? p : (FEN_PROV_SHORT[p] || p)}</Badge>
    </span>
  );
}

// The sentence that has to appear on anything that would be fabricated. It
// names the profiles rather than counting them, because "3 unverified sections"
// tells nobody which bar to go and check.
function FenFabricationNotice({ profiles, what }) {
  const unverified = fenUnverifiedAmong(profiles);
  if (!unverified.length) return null;
  const approx = unverified.filter(p => fenProvenanceOf(p) === FEN_PROV_APPROX);
  return (
    <div className={`rounded-lg border px-3 py-2 text-xs ${approx.length ? 'border-red-200 bg-red-50 text-red-800' : 'border-amber-200 bg-amber-50 text-amber-900'}`}>
      <b>
        {what} rests on {unverified.length} section{unverified.length === 1 ? '' : 's'} that did not come
        from the manufacturer&rsquo;s CAD.
      </b>{' '}
      {approx.length
        ? `${approx.length} of ${unverified.length === 1 ? 'them' : `the ${unverified.length}`} ${approx.length === 1 ? 'is' : 'are'} marked “${FEN_PROV_APPROX}”. Nothing may be cut to those.`
        : 'They were typed by hand off a dimensioned drawing. Check them against the named source before anything is cut.'}
      <div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
        {unverified.map(p => (
          <span key={p.id} className="inline-flex items-center gap-1">
            <b>{p.code || p.name || 'Untitled profile'}</b>
            <FenProvenanceTag profile={p} />
            {p.sourceDocName ? <span className="opacity-70">from {p.sourceDocName}</span> : null}
          </span>
        ))}
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════ Profile viewer
// Drawn FROM the stored coordinates, every time. The SVG below is a picture of
// the record; it is never a source of anything.
function FenProfileView({ geometry, height, zoom, showChambers, showDims, showOrientation }) {
  const geo = geometry;
  if (!geo || !geo.entities || !geo.entities.length) {
    return (
      <div className="text-xs text-[var(--leon-black)]/45 p-6 text-center">
        No geometry on file for this profile.
      </div>
    );
  }
  const H = height || 320;
  const b = geo.bounds;
  const pad = Math.max(4, Math.max(b.width, b.depth) * 0.12);
  const spanX = b.width + pad * 2, spanY = b.depth + pad * 2;
  const s = (H / spanY) * (zoom || 1);
  // CAD y is up, SVG y is down — the flip lives here, in the picture, and never
  // in the coordinates.
  const X = mm => (mm - b.minX + pad) * s;
  const Y = mm => (b.maxY - mm + pad) * s;
  const INK = 'var(--leon-black)';
  const BROWN = 'var(--leon-brown)';

  const outer = (geo.closedLoops || []).find(l => l.kind === 'Outer boundary');
  const chambers = (geo.closedLoops || []).filter(l => l.kind === 'Chamber');
  const loopPath = pts => pts.map((p, i) => `${i ? 'L' : 'M'} ${X(p.x).toFixed(2)} ${Y(p.y).toFixed(2)}`).join(' ') + ' Z';

  function drawEntity(ent, i) {
    switch (ent.type) {
      case 'LINE':
        return <line key={i} x1={X(ent.start.x)} y1={Y(ent.start.y)} x2={X(ent.end.x)} y2={Y(ent.end.y)}
          stroke={INK} strokeWidth="1" />;
      case 'POLYLINE': {
        const pts = fenEntityPoints(ent);
        const d = pts.map((p, k) => `${k ? 'L' : 'M'} ${X(p.x).toFixed(2)} ${Y(p.y).toFixed(2)}`).join(' ') + (ent.closed ? ' Z' : '');
        return <path key={i} d={d} fill="none" stroke={INK} strokeWidth="1" />;
      }
      case 'ARC': case 'CIRCLE': case 'ELLIPSE': {
        const pts = fenEntityPoints(ent);
        if (pts.length < 2) return null;
        const d = pts.map((p, k) => `${k ? 'L' : 'M'} ${X(p.x).toFixed(2)} ${Y(p.y).toFixed(2)}`).join(' ');
        return <path key={i} d={d} fill="none" stroke={INK} strokeWidth="1" />;
      }
      default: return null;
    }
  }

  return (
    <svg viewBox={`0 0 ${(spanX * s).toFixed(1)} ${(spanY * s).toFixed(1)}`} width="100%" height={H}
      style={{ maxWidth: '100%' }} role="img" aria-label="Profile cross-section">
      {/* The outer boundary is filled so the extrusion reads as material, and
          the chambers are punched back out — which is what actually
          distinguishes a chamber from the boundary on screen. */}
      {showChambers !== false && outer && (
        <path d={loopPath(outer.points) + ' ' + chambers.map(c => loopPath(c.points)).join(' ')}
          fillRule="evenodd" fill="var(--leon-brown)" opacity="0.16" stroke="none" />
      )}
      {geo.entities.map(drawEntity)}
      {showDims !== false && (
        <g fontSize="11" fill={BROWN}>
          <line x1={X(b.minX)} y1={Y(b.minY) + 14} x2={X(b.maxX)} y2={Y(b.minY) + 14} stroke={BROWN} strokeWidth="0.8" />
          <text x={(X(b.minX) + X(b.maxX)) / 2} y={Y(b.minY) + 28} textAnchor="middle">{b.width.toFixed(1)} mm</text>
          <text x={X(b.minX) - 8} y={(Y(b.minY) + Y(b.maxY)) / 2} textAnchor="middle"
            transform={`rotate(-90 ${(X(b.minX) - 8).toFixed(1)} ${((Y(b.minY) + Y(b.maxY)) / 2).toFixed(1)})`}>
            {b.depth.toFixed(1)} mm
          </text>
        </g>
      )}
      {showOrientation && (
        <g fontSize="10" fill={INK} opacity="0.65">
          <text x={4} y={14}>{`Interior ${geo.orientation.interiorDir}`}</text>
          <text x={4} y={26}>{`Exterior ${geo.orientation.exteriorDir}`}</text>
        </g>
      )}
    </svg>
  );
}

// ═══════════════════════════════════════════════ Elevation
// Drawn from the assembly's numbers every time, like every other drawing in
// this app. Two conventions, stated here and in the legend on screen rather
// than left for the reader to infer:
//   · the elevation is viewed FROM THE EXTERIOR, and
//   · the apex of the dashed triangle marks the HINGED edge.
// The operation is also written on the bay in words, so nothing depends on
// anyone reading the symbol correctly.
function fenOperationMarks(op, x, y, w, h, px, key) {
  const D = 'var(--leon-black)';
  const dash = (x1, y1, x2, y2, k) => (
    <line key={`${key}-${k}`} x1={px(x1)} y1={px(y1)} x2={px(x2)} y2={px(y2)}
      stroke={D} strokeWidth="0.8" strokeDasharray="5 4" opacity="0.75" />
  );
  const cx = x + w / 2, cy = y + h / 2;
  const out = [];
  const casementLeft = () => { out.push(dash(x + w, y, x, cy, 'a'), dash(x + w, y + h, x, cy, 'b')); };
  const casementRight = () => { out.push(dash(x, y, x + w, cy, 'c'), dash(x, y + h, x + w, cy, 'd')); };
  const awning = () => { out.push(dash(x, y + h, cx, y, 'e'), dash(x + w, y + h, cx, y, 'f')); };
  const hopper = () => { out.push(dash(x, y, cx, y + h, 'g'), dash(x + w, y, cx, y + h, 'h')); };

  switch (op) {
    case 'Casement L': casementLeft(); break;
    case 'Casement R': casementRight(); break;
    // A tilt-turn does two things, so it carries both symbols: the side hinge
    // for the turn and the bottom hinge for the tilt.
    case 'Tilt-Turn L': casementLeft(); hopper(); break;
    case 'Tilt-Turn R': casementRight(); hopper(); break;
    case 'Awning': awning(); break;
    case 'Hopper': hopper(); break;
    case 'Slider':
      out.push(
        <line key={`${key}-s1`} x1={px(x + w * 0.2)} y1={px(cy)} x2={px(x + w * 0.8)} y2={px(cy)}
          stroke={D} strokeWidth="1.1" opacity="0.75" markerEnd="url(#fen-arrow)" />
      );
      break;
    case 'Door':
      casementRight();
      out.push(<line key={`${key}-th`} x1={px(x)} y1={px(y + h)} x2={px(x + w)} y2={px(y + h)}
        stroke="var(--leon-brown)" strokeWidth="2" />);
      break;
    case 'Spandrel': {
      const n = 5;
      for (let i = 1; i < n; i++) {
        const t = i / n;
        out.push(<line key={`${key}-sp${i}`} x1={px(x)} y1={px(y + h * t)} x2={px(x + w * t)} y2={px(y)}
          stroke={D} strokeWidth="0.6" opacity="0.35" />);
      }
      break;
    }
    default: break;                                    // Fixed draws nothing, correctly
  }
  return out;
}

function FenElevation({ computed, assembly, system, height, showDims, mark, interactive, selectedBayId, onPickBay }) {
  const C = computed;
  if (!C || !(C.W > 0) || !(C.H > 0)) {
    return <div className="text-xs text-[var(--leon-black)]/40 p-4">Enter an overall width and height to draw this assembly.</div>;
  }
  const pad = Math.max(160, C.H * 0.12);
  const totalW = C.W + pad * 2, totalH = C.H + pad * 2;
  const H = height || 360;
  const scale = H / totalH;
  const px = mm => mm * scale;
  const ox = pad, oy = pad;
  const INK = 'var(--leon-black)';
  const BROWN = 'var(--leon-brown)';
  // The elevation is drawn with y measured UP from the sill, so the maths reads
  // the way the schedule does; the flip to screen coordinates happens here.
  const sy = mm => oy + C.H - mm;
  const ff = C.frameFace;

  return (
    <svg viewBox={`0 0 ${px(totalW).toFixed(1)} ${px(totalH).toFixed(1)}`} width="100%" height={H}
      style={{ maxWidth: px(totalW) }} role="img" aria-label={`Fenestration elevation ${mark || ''}`}>
      <defs>
        <marker id="fen-arrow" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
          <path d="M0,0 L7,3.5 L0,7 z" fill="var(--leon-black)" opacity="0.7" />
        </marker>
        <marker id="fen-tick" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto">
          <line x1="3" y1="0" x2="3" y2="6" stroke="var(--leon-brown)" strokeWidth="0.9" />
        </marker>
      </defs>

      {/* Frame. When no face width is known the frame is one line, not an
          invented rectangle — the drawing says what is known and no more. */}
      <rect x={px(ox)} y={px(sy(C.H))} width={px(C.W)} height={px(C.H)} fill="#fff" stroke={INK} strokeWidth="2" />
      {ff > 0 && (
        <rect x={px(ox + ff)} y={px(sy(C.H - ff))} width={px(C.innerW)} height={px(C.innerH)}
          fill="none" stroke={INK} strokeWidth="1" />
      )}

      {C.rows.map(r => (
        <g key={r.id}>
          {r.index > 0 && (
            C.transomFace > 0
              ? <rect x={px(ox + C.innerX)} y={px(sy(r.y))} width={px(C.innerW)} height={px(C.transomFace)}
                  fill="var(--leon-line)" stroke={INK} strokeWidth="0.9" />
              : <line x1={px(ox + C.innerX)} y1={px(sy(r.y))} x2={px(ox + C.innerX + C.innerW)} y2={px(sy(r.y))}
                  stroke={INK} strokeWidth="1.4" />
          )}
          {r.bays.map((cell, bi) => {
            const bx = ox + cell.x, byTop = sy(cell.y + cell.h);
            const sel = interactive && selectedBayId === cell.id;
            return (
              <g key={cell.id} onClick={interactive && onPickBay ? () => onPickBay(cell.id) : undefined}
                style={interactive ? { cursor: 'pointer' } : undefined}>
                <rect x={px(bx)} y={px(byTop)} width={px(cell.w)} height={px(cell.h)}
                  fill={sel ? 'var(--leon-cream)' : '#f6fafc'} stroke={INK} strokeWidth="0.9"
                  opacity={cell.bay.operation === 'Spandrel' ? 0.55 : 1} />
                {sel && <rect x={px(bx)} y={px(byTop)} width={px(cell.w)} height={px(cell.h)}
                  fill="none" stroke={BROWN} strokeWidth="2.4" />}
                {/* The sash line, only where a sash actually exists. */}
                {FEN_OPERABLE(cell.bay.operation) && (
                  <rect x={px(bx + cell.w * 0.03)} y={px(byTop + cell.h * 0.03)}
                    width={px(cell.w * 0.94)} height={px(cell.h * 0.94)}
                    fill="none" stroke={INK} strokeWidth="0.8" opacity="0.55" />
                )}
                {/* The symbol is drawn in SCREEN space — byTop, not the bay's
                    height above the sill — because an apex at the top has to be
                    at the top of the picture, not the top of the maths. */}
                {fenOperationMarks(cell.bay.operation, bx, byTop, cell.w, cell.h, px, cell.id)}
                {/* Marks and sizes belong ON the bay: a bay that says CSM-L
                    900 needs no legend to be understood. */}
                <text x={px(bx + cell.w / 2)} y={px(byTop + cell.h * 0.5)} textAnchor="middle"
                  fontSize={Math.max(8, px(70))} fill={INK} opacity="0.7">
                  {FEN_OPERATION_ABBR[cell.bay.operation] || cell.bay.operation}
                </text>
                {showDims && (
                  <text x={px(bx + cell.w / 2)} y={px(byTop + cell.h * 0.5 + 90)} textAnchor="middle"
                    fontSize={Math.max(7, px(58))} fill={BROWN}>
                    {fmtDim(cell.w, system, { inchesOnly: true })}
                  </text>
                )}
              </g>
            );
          })}
          {r.bays.map((cell, bi) => (bi > 0 && C.mullionFace > 0 ? (
            <rect key={`m-${cell.id}`} x={px(ox + cell.x - C.mullionFace)} y={px(sy(r.y + r.h))}
              width={px(C.mullionFace)} height={px(r.h)} fill="var(--leon-line)" stroke={INK} strokeWidth="0.9" />
          ) : (bi > 0 ? (
            <line key={`m-${cell.id}`} x1={px(ox + cell.x)} y1={px(sy(r.y))} x2={px(ox + cell.x)} y2={px(sy(r.y + r.h))}
              stroke={INK} strokeWidth="1.4" />
          ) : null)))}
        </g>
      ))}

      {/* Floor / sill reference line — the elevation is only readable with one. */}
      <line x1={px(ox - 40)} y1={px(sy(0))} x2={px(ox + C.W + 40)} y2={px(sy(0))} stroke={INK} strokeWidth="1.6" />

      {showDims && (
        <g fontSize={Math.max(8, px(80))} fill={BROWN} fontFamily="inherit">
          <line x1={px(ox)} y1={px(sy(0) + 60)} x2={px(ox + C.W)} y2={px(sy(0) + 60)}
            stroke={BROWN} strokeWidth="0.9" markerStart="url(#fen-tick)" markerEnd="url(#fen-tick)" />
          <text x={px(ox + C.W / 2)} y={px(sy(0) + 110)} textAnchor="middle">{fmtDim(C.W, system, { inchesOnly: true })}</text>
          <text x={px(ox - 46)} y={px(sy(C.H / 2))} textAnchor="middle"
            transform={`rotate(-90 ${px(ox - 46).toFixed(1)} ${px(sy(C.H / 2)).toFixed(1)})`}>
            {fmtDim(C.H, system, { inchesOnly: true })}
          </text>
          <text x={px(ox + C.W / 2)} y={px(oy - 40)} textAnchor="middle" fill={INK} opacity="0.6">
            {mark ? `${mark} · ` : ''}RO {fmtDim(C.ro.w, system, { inchesOnly: true })} × {fmtDim(C.ro.h, system, { inchesOnly: true })}
          </text>
        </g>
      )}
    </svg>
  );
}


// ============================================================================
// THE WINDOW SHOP DRAWING
// ============================================================================
// The same sheet the door, countertop and tile drawings use — a real paper
// size, a stated scale, LEON's own lockup, a side panel of information, and the
// finishes and selections along the bottom. Written in LITERAL hex rather than
// CSS variables: a custom property resolves only while the drawing is inside
// the page, so an exported sheet paints every filled shape black. That was a
// real defect in the tile module and there is no reason to repeat it here.
const FEN_INK = '#2B2118', FEN_BROWN = '#8B5E34', FEN_LINE = '#D9D2C7', FEN_CREAM = '#F3EFE9';
const FEN_GLASS = '#dce9f0';
const FEN_FONT = "'Century Gothic Leon', 'Century Gothic', Questrial, sans-serif";
const FEN_SW = { cut: 0.62, outline: 0.42, detail: 0.26, thin: 0.16 };
const FEN_SHEET_DIM_MM = 260;    // room outside the elevation for the dimension bands

function fenSheetSize(key) {
  const list = (typeof SHEET_SIZES !== 'undefined' && SHEET_SIZES) || [{ key: 'A2', w: 594, h: 420 }];
  return list.find(x => x.key === key) || list.find(x => x.key === 'A2') || list[0];
}
function fenFitDenom(mm, paper, detail) {
  const src = detail
    ? ((typeof SHEET_DETAIL_SCALES !== 'undefined' && SHEET_DETAIL_SCALES) || [{ denom: 5 }])
    : ((typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || [{ denom: 20 }]);
  const ladder = src.map(x => x.denom).sort((a, b) => a - b);
  for (const dn of ladder) if (mm / dn <= paper) return dn;
  return ladder[ladder.length - 1];
}
function fenScaleLabel(dn, detail) {
  const src = detail
    ? ((typeof SHEET_DETAIL_SCALES !== 'undefined' && SHEET_DETAIL_SCALES) || [])
    : ((typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || []);
  const f = src.find(x => x.denom === dn);
  return f ? f.label : `1:${dn}`;
}
function FenSheetDim({ x1, y1, x2, y2, text, tone }) {
  const dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy) || 1;
  const nx = -dy / len, ny = dx / len, t = 1.3;
  const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
  const ang = Math.atan2(dy, dx) * 180 / Math.PI;
  const flip = ang > 90 || ang < -90;
  const col = tone || FEN_BROWN;
  return (
    <g stroke={col} strokeWidth={FEN_SW.thin} fill="none">
      <line x1={x1} y1={y1} x2={x2} y2={y2} />
      <line x1={x1 - nx * t} y1={y1 - ny * t} x2={x1 + nx * t} y2={y1 + ny * t} />
      <line x1={x2 - nx * t} y1={y2 - ny * t} x2={x2 + nx * t} y2={y2 + ny * t} />
      <text x={mx} y={my - 1.3} fontSize="2.5" fill={col} stroke="none" fontFamily={FEN_FONT}
        textAnchor="middle" transform={`rotate(${flip ? ang + 180 : ang} ${mx} ${my})`}>{text}</text>
    </g>
  );
}
function FenSheetBox({ x, y, w, h, title, scaleNote, children }) {
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="none" stroke={FEN_INK}
        strokeWidth={FEN_SW.thin} opacity="0.45" />
      <text x={x + 1.5} y={y + 3.6} fontSize="2.6" fontWeight="bold" fill={FEN_BROWN}
        fontFamily={FEN_FONT} letterSpacing="0.9">{title}</text>
      {scaleNote && (
        <text x={x + w - 1.5} y={y + 3.6} fontSize="2" fill={FEN_INK} opacity="0.5"
          fontFamily={FEN_FONT} textAnchor="end">{scaleNote}</text>
      )}
      <line x1={x} y1={y + 5} x2={x + w} y2={y + 5} stroke={FEN_INK} strokeWidth={FEN_SW.thin} opacity="0.35" />
      {children}
    </g>
  );
}

// The assembly at scale: frame, mullions, transoms, glass, and the operable
// sashes marked the way a window elevation marks them — a V for the hinge side
// of a casement, an arrow for a slider. Nothing re-derived: it draws the same
// `computed` the designer draws.
function FenSheetElevation({ computed, assembly, x, y, w, h, denom, system }) {
  const C = computed;
  const sp = mm => mm / denom;
  const ox = x + (w - sp(C.W)) / 2, oy = y + 5 + (h - 5 - sp(C.H)) / 2;
  const fmt = mm => fmtDim(mm, system, { inchesOnly: true });
  const sy = mm => oy + sp(C.H) - sp(mm);          // y measured up from the sill

  return (
    <g>
      {/* The frame, as a solid section with the glazed opening knocked out. */}
      <rect x={ox} y={oy} width={sp(C.W)} height={sp(C.H)} fill="#efe9e0"
        stroke={FEN_INK} strokeWidth={FEN_SW.cut} />
      {C.bays.map(b => (
        <g key={b.id}>
          <rect x={ox + sp(b.x)} y={sy(b.y + b.h)} width={sp(b.w)} height={sp(b.h)}
            fill={FEN_GLASS} stroke={FEN_INK} strokeWidth={FEN_SW.outline} />
          {/* The operation, drawn. A window schedule that names an operation and
              does not show it makes the reviewer take it on trust. */}
          {(() => {
            const op = String((b.bay && b.bay.operation) || '').toLowerCase();
            const x1 = ox + sp(b.x), x2 = ox + sp(b.x + b.w);
            const y1 = sy(b.y + b.h), y2 = sy(b.y);
            const mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
            const line = (a, bb, c, d) => (
              <line x1={a} y1={bb} x2={c} y2={d} stroke={FEN_INK} strokeWidth={FEN_SW.detail}
                strokeDasharray="2,1.4" opacity="0.75" />
            );
            if (/casement|awning|hopper|tilt/.test(op)) {
              // The V points at the HINGE side, which is the convention.
              if (/awning/.test(op)) return <g>{line(x1, y2, mid.x, y1)}{line(x2, y2, mid.x, y1)}</g>;
              if (/hopper/.test(op)) return <g>{line(x1, y1, mid.x, y2)}{line(x2, y1, mid.x, y2)}</g>;
              const right = /right/.test(op);
              return right
                ? <g>{line(x2, y1, x1, mid.y)}{line(x2, y2, x1, mid.y)}</g>
                : <g>{line(x1, y1, x2, mid.y)}{line(x1, y2, x2, mid.y)}</g>;
            }
            if (/slid|glid/.test(op)) {
              const ax = x1 + (x2 - x1) * 0.25, bx = x1 + (x2 - x1) * 0.75;
              return (
                <g stroke={FEN_INK} strokeWidth={FEN_SW.detail} opacity="0.75">
                  <line x1={ax} y1={mid.y} x2={bx} y2={mid.y} />
                  <path d={`M${bx},${mid.y} L${bx - 2},${mid.y - 1.4} M${bx},${mid.y} L${bx - 2},${mid.y + 1.4}`} fill="none" />
                </g>
              );
            }
            return null;
          })()}
          <text x={ox + sp(b.x + b.w / 2)} y={sy(b.y + b.h / 2) + 0.8} fontSize="2"
            fill={FEN_INK} fontFamily={FEN_FONT} textAnchor="middle" opacity="0.7">
            {(b.bay && b.bay.operation) || 'Fixed'}
          </text>
        </g>
      ))}

      {/* Bay widths across the bottom, then the overall beneath them. */}
      {(C.rows[0] ? C.rows[0].bays : []).map(b => (
        <FenSheetDim key={`bw${b.id}`} x1={ox + sp(b.x)} y1={oy + sp(C.H) + 8}
          x2={ox + sp(b.x + b.w)} y2={oy + sp(C.H) + 8} text={fmt(b.w)} />
      ))}
      <FenSheetDim x1={ox} y1={oy + sp(C.H) + 16} x2={ox + sp(C.W)} y2={oy + sp(C.H) + 16} text={fmt(C.W)} />
      {/* Row heights up the left, then the overall, then the rough opening —
          three trades read three different figures off a window. */}
      {C.rows.map(r => (
        <FenSheetDim key={`rh${r.id}`} x1={ox - 8} y1={sy(r.y)} x2={ox - 8} y2={sy(r.y + r.h)} text={fmt(r.h)} />
      ))}
      <FenSheetDim x1={ox - 16} y1={oy} x2={ox - 16} y2={oy + sp(C.H)} text={fmt(C.H)} />
      {C.ro && C.ro.w > 0 && (
        <g>
          <rect x={ox - sp((C.ro.w - C.W) / 2)} y={oy - sp(C.ro.h - C.H)}
            width={sp(C.ro.w)} height={sp(C.ro.h)} fill="none" stroke={FEN_INK}
            strokeWidth={FEN_SW.detail} strokeDasharray="3,2" opacity="0.6" />
          <text x={ox + sp(C.W / 2)} y={oy - sp(C.ro.h - C.H) - 2} fontSize="2"
            fill={FEN_INK} fontFamily={FEN_FONT} textAnchor="middle" opacity="0.6">
            ROUGH OPENING {fmt(C.ro.w)} × {fmt(C.ro.h)}
          </text>
        </g>
      )}
    </g>
  );
}

function FenSheetPanel({ project, ctx, inst, res, x, y, w, h, system }) {
  const A = res.assembly, C = res.computed;
  const fmt = mm => fmtDim(mm, system, { inchesOnly: true });
  const co = (ctx && ctx.companyProfile) || {};
  const rows = [
    ['MARK', (inst && inst.mark) || ''],
    ['TYPE', (res.type && (res.type.code || res.type.name)) || '—'],
    ['FRAME', `${fmt(C.W)} × ${fmt(C.H)}`],
    ['ROUGH OPENING', C.ro && C.ro.w ? `${fmt(C.ro.w)} × ${fmt(C.ro.h)}` : '—'],
    ['SILL HEIGHT', qnum(A.sillHeight) ? fmt(qnum(A.sillHeight)) : '—'],
    ['BAYS', `${C.bays.length} in ${C.rows.length} row${C.rows.length === 1 ? '' : 's'}`],
    ['GLASS', A.glass || '— not selected —'],
    ['FINISH', A.finish || '— not selected —'],
  ];
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="#ffffff" stroke={FEN_INK}
        strokeWidth={FEN_SW.thin} opacity="0.9" />
      <rect x={x} y={y} width={w} height={40} fill={FEN_CREAM} />
      <image href="logo/leon-official.svg" x={x + w / 2 - 15} y={y + 2} width="30" height="36"
        preserveAspectRatio="xMidYMid meet" />
      <line x1={x} y1={y + 40} x2={x + w} y2={y + 40} stroke={FEN_BROWN} strokeWidth="0.5" />
      <text x={x + 2} y={y + 45} fontSize="2.6" fill={FEN_INK} fontFamily={FEN_FONT} fontWeight="bold">
        {String((project && project.name) || '').slice(0, 28)}
      </text>
      <text x={x + 2} y={y + 48.6} fontSize="1.9" fill={FEN_INK} fontFamily={FEN_FONT} opacity="0.55">
        {(project && project.projectNumber) || ''}
      </text>
      <line x1={x} y1={y + 50.5} x2={x + w} y2={y + 50.5} stroke={FEN_LINE} strokeWidth={FEN_SW.thin} />
      {rows.map(([k, v], i) => (
        <g key={k}>
          <text x={x + 2} y={y + 56 + i * 7} fontSize="1.8" fill={FEN_INK} fontFamily={FEN_FONT}
            opacity="0.45" letterSpacing="0.5">{k}</text>
          <text x={x + 2} y={y + 59.4 + i * 7} fontSize="2.4" fill={FEN_INK} fontFamily={FEN_FONT}>
            {String(v == null ? '' : v).slice(0, 24)}
          </text>
          <line x1={x + 2} y1={y + 61 + i * 7} x2={x + w - 2} y2={y + 61 + i * 7}
            stroke={FEN_LINE} strokeWidth={FEN_SW.thin} opacity="0.6" />
        </g>
      ))}
      <text x={x + 2} y={y + h - 7} fontSize="1.8" fill={FEN_INK} fontFamily={FEN_FONT} opacity="0.45">
        {String(co.name || 'LEON INTEGRA').toUpperCase()}
      </text>
      <text x={x + 2} y={y + h - 3.6} fontSize="1.7" fill={FEN_INK} fontFamily={FEN_FONT} opacity="0.4">
        {[co.addressLine1, co.phone].filter(Boolean).join(' · ').slice(0, 34)}
      </text>
    </g>
  );
}

function FenSheetFinishes({ res, ctx, x, y, w, h, system }) {
  const A = res.assembly;
  const sysRec = A.systemId && typeof fenLibrary === 'function' ? null : null;
  const cards = [
    { tag: 'SYSTEM', name: A.systemName || A.systemId || 'Not selected',
      sub: A.systemVerified ? 'Manufacturer CAD verified' : 'Awaiting verified manufacturer CAD',
      warn: !A.systemVerified, img: (A.systemRef && A.systemRef.img) || '' },
    { tag: 'GLASS', name: A.glass || 'Not selected', sub: A.glassSpec || 'Specify the make-up',
      img: (A.glassRef && A.glassRef.img) || '' },
    { tag: 'FINISH', name: (A.finishRef && A.finishRef.name) || A.finish || 'Not selected',
      sub: A.finishRef ? [A.finishRef.supplier, A.finishRef.code].filter(Boolean).join(' · ')
                       : 'Link a finish from the supplier library',
      warn: !A.finishRef, img: (A.finishRef && A.finishRef.img) || '' },
    { tag: 'HARDWARE', name: A.hardware || 'Not selected', sub: A.hardwareSpec || '',
      img: (A.hardwareRef && A.hardwareRef.img) || '' },
  ];
  const cw = (w - 2) / cards.length;
  const imgH = Math.max(14, h - 20);
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="#ffffff" stroke={FEN_INK}
        strokeWidth={FEN_SW.thin} opacity="0.9" />
      <text x={x + 2} y={y + 4} fontSize="2.4" fontWeight="bold" fill={FEN_BROWN}
        fontFamily={FEN_FONT} letterSpacing="1">FINISHES &amp; SELECTIONS</text>
      <line x1={x} y1={y + 5.6} x2={x + w} y2={y + 5.6} stroke={FEN_INK} strokeWidth={FEN_SW.thin} opacity="0.35" />
      {cards.map((c, i) => {
        const cx = x + 1 + i * cw;
        return (
          <g key={i}>
            {c.img
              ? <image href={c.img} x={cx + 1} y={y + 7} width={cw - 3} height={imgH}
                  preserveAspectRatio="xMidYMid slice" />
              : <rect x={cx + 1} y={y + 7} width={cw - 3} height={imgH} fill={FEN_CREAM}
                  stroke={FEN_LINE} strokeWidth={FEN_SW.thin} />}
            <rect x={cx + 1} y={y + 7} width={cw - 3} height={imgH} fill="none"
              stroke={FEN_INK} strokeWidth={FEN_SW.thin} opacity="0.4" />
            <text x={cx + 2.5} y={y + 10.5} fontSize="1.8" fill={FEN_BROWN} fontFamily={FEN_FONT}
              fontWeight="bold" letterSpacing="0.6">{c.tag}</text>
            <text x={cx + 1} y={y + 7 + imgH + 4} fontSize="2.2" fill={FEN_INK} fontFamily={FEN_FONT}>
              {String(c.name || '').slice(0, Math.floor(cw / 1.15))}
            </text>
            <text x={cx + 1} y={y + 7 + imgH + 7.4} fontSize="1.8"
              fill={c.warn ? '#b83b3b' : FEN_INK} fontFamily={FEN_FONT} opacity={c.warn ? 0.95 : 0.55}>
              {String(c.sub || '').slice(0, Math.floor(cw / 1))}
            </text>
          </g>
        );
      })}
    </g>
  );
}

function FenShopDrawingPage({ project, ctx, inst, res, size, denom, system, autoFit, sheetNo }) {
  const S = fenSheetSize(size);
  const m = 7, panelW = 74, gap = 2.5;
  const drawW = S.w - m * 2 - panelW - gap;
  const top = m + 2;
  const footH = Math.max(42, Math.round(S.h * 0.135));
  const bodyH = S.h - m * 2 - 4 - footH - gap;
  const C = res.computed;
  const fit = Math.max(fenFitDenom(C.W + FEN_SHEET_DIM_MM, drawW - 6),
                       fenFitDenom(C.H + FEN_SHEET_DIM_MM, bodyH - 12));
  const dn = autoFit ? fit : denom;

  return (
    <svg width={`${S.w}mm`} height={`${S.h}mm`} viewBox={`0 0 ${S.w} ${S.h}`}
      style={{ background: '#fff', maxWidth: '100%', height: 'auto' }}
      role="img" aria-label={`Window shop drawing — ${(inst && inst.mark) || ''}`}>
      <rect x="0" y="0" width={S.w} height={S.h} fill="#ffffff" />
      <rect x={m / 2} y={m / 2} width={S.w - m} height={S.h - m}
        fill="none" stroke={FEN_INK} strokeWidth="0.5" />

      <FenSheetBox x={m} y={top} w={drawW} h={bodyH}
        title={`ELEVATION — ${String((inst && inst.mark) || 'ASSEMBLY').toUpperCase()}`}
        scaleNote={`SCALE ${fenScaleLabel(dn)}`}>
        <FenSheetElevation computed={C} assembly={res.assembly} x={m} y={top} w={drawW} h={bodyH}
          denom={dn} system={system} />
      </FenSheetBox>

      <FenSheetPanel project={project} ctx={ctx} inst={inst} res={res}
        x={m + drawW + gap} y={top} w={panelW} h={bodyH} system={system} />

      <FenSheetFinishes res={res} ctx={ctx} x={m} y={top + bodyH + gap}
        w={S.w - m * 2} h={footH} system={system} />

      <text x={S.w - m} y={S.h - m / 2 - 1.5} fontSize="2" fill={FEN_INK} fontFamily={FEN_FONT}
        textAnchor="end" opacity="0.5">{sheetNo || ''}</text>
    </svg>
  );
}

// One sheet per window mark. A window schedule is read mark by mark, so this
// paginates rather than cramming a whole elevation set onto one page.
function FenSheetTab({ ctx, project, system }) {
  const insts = (project && project.fenestrationInstances) || [];
  const [markId, setMarkId] = useState('');
  const [sizeKey, setSizeKey] = useState('A2');
  const [scaleKey, setScaleKey] = useState('fit');
  const ref = useRef(null);
  const inst = insts.find(i => i.id === markId) || insts[0] || null;
  const res = inst ? fenResolveInstance(project, inst) : null;
  const scales = (typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || [];
  const sizes = (typeof SHEET_SIZES !== 'undefined' && SHEET_SIZES) || [];

  if (!insts.length) {
    return <EmptyState text="No windows scheduled on this job yet. A sheet is drawn from a scheduled mark — add one under Window Schedule." />;
  }
  if (!res || !res.computed || !(res.computed.W > 0)) {
    return <EmptyState text="This mark has no overall size yet, so there is nothing to draw at scale." />;
  }

  return (
    <div className="space-y-3">
      <div>
        <h3 className="font-bold">📄 Shop Drawing</h3>
        <p className="text-sm text-[var(--leon-black)]/60 max-w-3xl">
          One window mark at a stated scale, with LEON&rsquo;s own lockup, the schedule figures down the side,
          and the system, glass, finish and hardware along the bottom &mdash; the same sheet model the door,
          countertop and tile drawings use. Drawn from the same resolved assembly the designer shows, so the
          sheet and the schedule cannot disagree.
        </p>
      </div>

      <div className="flex items-end gap-3 flex-wrap">
        <Field label="Mark">
          <Select className="!w-52" value={inst ? inst.id : ''} onChange={e => setMarkId(e.target.value)}>
            {insts.map(i => <option key={i.id} value={i.id}>{i.mark || i.id}</option>)}
          </Select>
        </Field>
        <Field label="Sheet size">
          <Select className="!w-48" value={sizeKey} onChange={e => setSizeKey(e.target.value)}>
            {sizes.map(z => <option key={z.key} value={z.key}>{z.label}</option>)}
          </Select>
        </Field>
        <Field label="Scale">
          <Select className="!w-44" value={scaleKey} onChange={e => setScaleKey(e.target.value)}>
            <option value="fit">Fit to the sheet</option>
            {scales.map(z => <option key={z.key} value={z.key}>{z.label}</option>)}
          </Select>
        </Field>
        <div className="ml-auto flex items-end gap-1.5">
          <IconAction icon="🖨" title="Print this sheet"
            onClick={() => printRegion(ref.current, {
              title: `${project.name} — ${inst.mark || ''}`, heading: 'Window shop drawing' })} />
        </div>
      </div>

      <div ref={ref} data-print-region="Window shop drawing"
        className="overflow-auto rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/40 p-3">
        <FenShopDrawingPage project={project} ctx={ctx} inst={inst} res={res}
          size={sizeKey} system={system} autoFit={scaleKey === 'fit'}
          denom={(scales.find(z => z.key === scaleKey) || {}).denom || 20}
          sheetNo={`WN-${String(insts.indexOf(inst) + 1).padStart(2, '0')}`} />
      </div>

      <p className="text-[11px] text-[var(--leon-black)]/45 max-w-3xl">
        A browser prints through the page, so the sheet size here sets the DRAWING size &mdash; choose matching
        paper in the print dialog and turn scaling off, or the scale in the header stops being true.
      </p>
    </div>
  );
}

function FenElevationLegend() {
  return (
    <div className="text-[11px] text-[var(--leon-black)]/55 leading-relaxed">
      Viewed from the <b>exterior</b>. The apex of the dashed triangle marks the <b>hinged edge</b> —
      a side apex is a casement, a top apex an awning, a bottom apex a hopper, and a tilt-turn carries
      both. Each bay is also labelled in words, so nothing rests on reading the symbol.
    </div>
  );
}

// ═══════════════════════════════════════════════ The module
function FenestrationSoftware({ ctx }) {
  const [section, setSection] = useState('dashboard');
  const [projectId, setProjectId] = useState('');
  const [system, setSystem] = useState('Metric');       // fenestration is specified in mm nearly everywhere
  const lib = useFenLibrary();

  // deptProjects is a FUNCTION that filters by the active department — calling
  // it is not optional, and treating it as an array takes the whole app down.
  // Real jobs, plus this person's own unassigned workspace. Work often
  // starts before there is a job to attach it to — and sometimes never
  // becomes one — so no tool should demand a project before it will open.
  const allForTools = typeof ctx.toolProjects === 'function' ? ctx.toolProjects() : (ctx.projects || []);
  const projects = ctx.deptProjects(allForTools);
  const scratchIds = new Set((ctx.scratchProjects || []).map(p => p.id));
  const project = projects.find(p => p.id === projectId) || null;
  const editable = ctx.canEdit('documents');
  const needsProject = ['schedule', 'designer', 'types'].includes(section);

  return (
    <div className="space-y-4">
      <div className="flex items-start justify-between gap-4 flex-wrap">
        <div>
          <h2 className="text-xl font-bold">🪟 LEON Windows — Fenestration &amp; Façade</h2>
          <p className="text-sm text-[var(--leon-black)]/60 max-w-3xl">
            Two halves, deliberately kept apart. The <b>assembly</b> — bays, sizes, which one opens — is
            ours and is redrawn from its numbers every time. The <b>profile</b> is the manufacturer&rsquo;s
            cross-section, held as real coordinates: parsed from their own CAD, or typed by a person off
            their own dimensioned drawing and tagged as such wherever it appears. Nothing in here will
            draw a section from a photograph or a datasheet picture.
          </p>
        </div>
        <div className="flex items-end gap-2 flex-wrap">
          <Field label="Project">
            <Select className="!w-56" value={scratchIds.has(projectId) ? '__scratch' : projectId}
              onChange={e => { const v = e.target.value; const id = v === '__scratch' && typeof ctx.myScratchProject === 'function' ? ctx.myScratchProject().id : v; setProjectId(id); }}>
              <option value="">— select a project —</option>
              {projects.filter(p => !scratchIds.has(p.id)).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
              <option value="__scratch">— Not linked to a job (my workspace) —</option>
            </Select>
          </Field>
          <Field label="Units" hint="Profile geometry always reads in mm.">
            <Select className="!w-32" value={system} onChange={e => setSystem(e.target.value)}>
              {FEN_UNIT_SYSTEMS.map(u => <option key={u}>{u}</option>)}
            </Select>
          </Field>
        </div>
      </div>


      <SoftwareRail swKey="fenestration" sections={FEN_SW_SECTIONS} active={section}
        onSelect={setSection}>
      {needsProject && !project ? (
        <div className="rounded-lg border border-dashed border-[var(--leon-line)] p-8 text-center">
          <div className="text-3xl mb-2">🪟</div>
          <div className="font-semibold mb-1">Pick a project</div>
          <div className="text-sm text-[var(--leon-black)]/55">
            Marks and types belong to a job. The manufacturer library, the profiles and the importer are
            shared and open without one.
          </div>
        </div>
      ) : (
        <>
          {section === 'dashboard' && <FenDashboard ctx={ctx} projects={projects} lib={lib}
            onOpen={(pid, s) => { setProjectId(pid); setSection(s); }} onGo={setSection} />}
          {section === 'schedule' && <FenSchedule ctx={ctx} project={project} system={system} editable={editable} onDesign={() => setSection('designer')} />}
          {section === 'designer' && <FenDesigner ctx={ctx} project={project} system={system} editable={editable} lib={lib} />}
          {section === 'types' && <FenTypesPanel ctx={ctx} project={project} system={system} editable={editable} lib={lib} />}
          {section === 'profiles' && <FenProfilesPanel ctx={ctx} lib={lib} editable={editable} onImport={() => setSection('import')} />}
          {section === 'import' && <FenImportPanel ctx={ctx} lib={lib} editable={editable} />}
          {section === 'systems' && <FenSystemsPanel ctx={ctx} lib={lib} editable={editable} />}
          {section === 'details' && <FenDetailsPanel ctx={ctx} project={project} lib={lib} system={system} />}
          {section === 'sheet' && <FenSheetTab ctx={ctx} project={project} system={system} />}
          {section === 'bom' && <FenBomPanel ctx={ctx} project={project} lib={lib} system={system} />}
        </>
      )}
      </SoftwareRail>
    </div>
  );
}

function FenDashboard({ ctx, projects, lib, onOpen, onGo }) {
  const rows = projects.map(p => {
    const insts = p.fenestrationInstances || [];
    const types = p.fenestrationTypes || [];
    const byStatus = {};
    insts.forEach(i => { byStatus[i.status || 'Draft'] = (byStatus[i.status || 'Draft'] || 0) + 1; });
    return { p, insts, types, byStatus, qty: insts.reduce((a, i) => a + (Number(i.qty) || 1), 0) };
  }).filter(r => r.insts.length || r.types.length);

  const withGeo = fenProfilesWithGeometry().length;
  const verified = fenProfilesVerifiedCad().length;
  const hand = fenProfilesHandEntered().length;
  const awaiting = lib.systems.filter(s => fenSystemStatusFor(s.id, lib.profiles) === FEN_AWAITING_CAD).length;

  return (
    <div className="space-y-4">
      {/* The one thing anyone opening this module needs to know, said first —
          and it is now two different things depending on what is in the
          library, because "no CAD" and "no profiles at all" are not the same
          situation and should not read the same. */}
      <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)] p-4">
        <div className="font-bold mb-1">
          {verified
            ? `${verified} profile${verified === 1 ? '' : 's'} came from verified manufacturer CAD${hand ? `, and ${hand} ${hand === 1 ? 'was' : 'were'} entered by hand.` : '.'}`
            : hand
              ? `No verified manufacturer CAD yet — the library is running on ${hand} hand-entered section${hand === 1 ? '' : 's'}.`
              : 'No profile sections on file yet.'}
        </div>
        <p className="text-sm text-[var(--leon-black)]/70 max-w-3xl">
          {lib.manufacturers.length} manufacturer and {lib.systems.length} systems are recorded;
          {' '}{awaiting} {awaiting === 1 ? 'is' : 'are'} still marked <b>{FEN_AWAITING_CAD}</b>, which a
          hand-entered section does not change — typing a profile makes a system workable, it does not make
          it verified. Cross-sections are never guessed from a photograph or a datasheet picture here. They
          are parsed from the manufacturer&rsquo;s file, or typed by a person off the manufacturer&rsquo;s
          dimensioned drawing and tagged as such everywhere they appear — including on the cut list that
          goes next to a saw.
        </p>
        <p className="text-xs text-[var(--leon-black)]/55 mt-2 max-w-3xl">
          <b>What to supply:</b> {FEN_SUPPLY_LINE}
        </p>
        <div className="mt-3">
          <Button size="sm" onClick={() => onGo('import')}>Open the importer</Button>
          <Button size="sm" variant="ghost" className="ml-2" onClick={() => onGo('profiles')}>Enter a profile by hand</Button>
          <Button size="sm" variant="ghost" className="ml-2" onClick={() => onGo('systems')}>See the systems</Button>
        </div>
      </div>

      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
        {[['Marks on the books', rows.reduce((a, r) => a + r.qty, 0)],
          ['Projects with fenestration', rows.length],
          ['Systems recorded', lib.systems.length],
          ['Systems awaiting CAD', awaiting],
          ['Profiles with usable geometry', withGeo],
          ['Of those, verified CAD', verified]].map(([k, v]) => (
          <div key={k} className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
            <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/40">{k}</div>
            <div className="text-2xl font-bold text-[var(--leon-brown)]">{v}</div>
          </div>
        ))}
      </div>

      {!rows.length && <EmptyState text="No fenestration on any project yet. Pick a project, create a type, then add marks in the Window Schedule." />}
      <div className="grid gap-3 md:grid-cols-2">
        {rows.map(r => (
          <button key={r.p.id} onClick={() => onOpen(r.p.id, 'schedule')}
            className="text-left rounded-lg border border-[var(--leon-line)] bg-white p-3 hover:border-[var(--leon-brown)]">
            <div className="font-bold">{r.p.name}</div>
            <div className="text-xs text-[var(--leon-black)]/50 mb-2">
              {r.insts.length} mark{r.insts.length === 1 ? '' : 's'} · {r.qty} unit total · {r.types.length} type{r.types.length === 1 ? '' : 's'}
            </div>
            <div className="flex flex-wrap gap-1">
              {Object.entries(r.byStatus).map(([k, v]) => <Badge key={k}>{k} {v}</Badge>)}
            </div>
          </button>
        ))}
      </div>
    </div>
  );
}

// ── Manufacturers & Systems ───────────────────────────────────────────────
function FenSystemsPanel({ ctx, lib, editable }) {
  const upd = (id, f) => fenLibraryUpdate(l => ({ ...l, systems: l.systems.map(s => s.id === id ? { ...s, ...f } : s) }));
  const updM = (id, f) => fenLibraryUpdate(l => ({ ...l, manufacturers: l.manufacturers.map(m => m.id === id ? { ...m, ...f } : m) }));
  const vendors = ctx.vendors || [];

  return (
    <div className="space-y-4">
      <div>
        <h3 className="font-bold">Manufacturers &amp; Systems</h3>
        <p className="text-sm text-[var(--leon-black)]/55 max-w-3xl">
          A system is a product line, and it is only usable here once its profiles arrive as geometry.
          The published width and depth are left blank on purpose: they are the figures an import is
          checked against, so they have to be typed off the manufacturer&rsquo;s own datasheet by a person.
          A remembered number checking a parsed number is two guesses agreeing.
        </p>
      </div>
      <FenSessionNotice />

      {lib.manufacturers.map(m => {
        const systems = lib.systems.filter(s => s.manufacturerId === m.id);
        return (
          <div key={m.id} className="rounded-lg border border-[var(--leon-line)] bg-white p-4 space-y-3">
            <div className="flex items-center gap-3 flex-wrap">
              <div className="text-lg font-bold">{m.name}</div>
              <span className="text-xs text-[var(--leon-black)]/45">{m.country}{m.website ? ` · ${m.website}` : ''}</span>
              <div className="ml-auto">
                <Field label="Linked vendor" hint="Points at the vendor record; it does not copy it.">
                  <Select className="!w-56" value={m.vendorId || ''} disabled={!editable}
                    onChange={e => updM(m.id, { vendorId: e.target.value || null })}>
                    <option value="">— not linked —</option>
                    {vendors.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
                  </Select>
                </Field>
              </div>
            </div>
            {m.notes && <div className="text-xs text-[var(--leon-black)]/50">{m.notes}</div>}

            <div className="overflow-x-auto">
              <table className="w-full text-xs min-w-[900px]">
                <thead>
                  <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                    <th className="px-2 py-2">System</th>
                    <th className="px-2 py-2">Material</th>
                    <th className="px-2 py-2">Profiles on file</th>
                    <th className="px-2 py-2">Geometry</th>
                    <th className="px-2 py-2">Published width (mm)</th>
                    <th className="px-2 py-2">Published depth (mm)</th>
                    <th className="px-2 py-2">Datasheet reference</th>
                  </tr>
                </thead>
                <tbody>
                  {systems.map(s => {
                    const mine = lib.profiles.filter(p => p.systemId === s.id);
                    const cad = mine.filter(fenIsVerifiedCad).length;
                    const byHand = mine.length - cad;
                    // A system awaits CAD until CAD arrives. Sections typed off
                    // a drawing make it workable and do not end the wait, so
                    // the badge does not pretend they did.
                    const status = fenSystemStatusFor(s.id, lib.profiles);
                    return (
                      <tr key={s.id} className="border-b border-[var(--leon-line)]/60">
                        <td className="px-2 py-1.5 font-semibold">{s.name}</td>
                        <td className="px-2 py-1.5 text-[var(--leon-black)]/60">{s.material}</td>
                        <td className="px-2 py-1.5">
                          {mine.length}
                          {mine.length ? <div className="text-[10px] text-[var(--leon-black)]/45">{cad} verified CAD · {byHand} hand-entered</div> : null}
                        </td>
                        <td className="px-2 py-1.5">
                          <Badge tone={status === FEN_AWAITING_CAD ? 'yellow' : 'green'}>{status}</Badge>
                          {status === FEN_AWAITING_CAD && byHand > 0 && (
                            <div className="text-[10px] text-amber-800 mt-0.5">
                              workable on {byHand} hand-entered section{byHand === 1 ? '' : 's'}
                            </div>
                          )}
                        </td>
                        {['publishedWidthMm', 'publishedDepthMm'].map(k => (
                          <td key={k} className="px-2 py-1.5">
                            <input type="number" value={s[k] === null || s[k] === undefined ? '' : s[k]}
                              disabled={!editable} placeholder="from the datasheet"
                              onChange={e => upd(s.id, { [k]: e.target.value === '' ? null : Number(e.target.value) })}
                              className="w-32 px-1 py-0.5 border border-[var(--leon-line)] rounded" />
                          </td>
                        ))}
                        <td className="px-2 py-1.5">
                          <input value={s.publishedSource || ''} disabled={!editable}
                            placeholder="e.g. datasheet page / revision"
                            onChange={e => upd(s.id, { publishedSource: e.target.value })}
                            className="w-56 px-1 py-0.5 border border-transparent hover:border-[var(--leon-line)] rounded bg-transparent" />
                        </td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ── Profile Library ───────────────────────────────────────────────────────
function FenProfilesPanel({ ctx, lib, editable, onImport }) {
  const [q, setQ] = useState('');
  const [prov, setProv] = useState('');
  const [open, setOpen] = useState(null);
  // {base, dup} — null when the library is showing. Keyed on mount so opening a
  // second profile never inherits the first one's half-typed points.
  const [editing, setEditing] = useState(null);
  const [flash, setFlash] = useState('');

  const shown = lib.profiles.filter(p => (!q.trim()
    || `${p.code} ${p.name} ${p.category} ${p.sourceDocName || ''}`.toLowerCase().includes(q.trim().toLowerCase()))
    && (!prov || fenProvenanceOf(p) === prov));
  const verified = lib.profiles.filter(fenIsVerifiedCad).length;
  const hand = lib.profiles.length - verified;

  if (editing) {
    return (
      <FenProfileEditor
        key={`${editing.base ? editing.base.id : 'new'}-${editing.dup ? 'dup' : 'edit'}`}
        ctx={ctx} lib={lib} base={editing.base} dup={editing.dup} editable={editable}
        onClose={() => setEditing(null)}
        onSaved={p => {
          setEditing(null);
          setFlash(`${p.code || 'Profile'} saved — ${fenProvenanceOf(p)}.`);
          setOpen(p);
        }} />
    );
  }

  return (
    <div className="space-y-3">
      <div className="flex items-start justify-between gap-3 flex-wrap">
        <div>
          <h3 className="font-bold">Profile Library</h3>
          <p className="text-sm text-[var(--leon-black)]/55 max-w-3xl">
            One record per manufacturer cross-section, held as coordinates in millimetres with its source
            still attached. A profile is placed by transform wherever it is used — the same extrusion
            serves the head, both jambs and the sill, so there is never a second copy to fall out of step
            with the first. Every profile carries <b>how it was obtained</b>, and that travels with it onto
            the schedule, the details, the BOM and the cut list.
          </p>
        </div>
        {editable && (
          <div className="flex gap-2">
            <Button size="sm" variant="ghost" onClick={() => setEditing({ base: null, dup: false })}>+ Enter by hand</Button>
            <Button size="sm" onClick={onImport}>📥 Import CAD</Button>
          </div>
        )}
      </div>

      {flash && <div className="rounded border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-800">{flash}</div>}

      {!lib.profiles.length ? (
        <FenGap title="There are no profiles yet. There are two ways to add one, and they are not equal."
          what={FEN_SUPPLY_LINE}>
          <b>The manufacturer&rsquo;s own CAD is preferred, and it is the only thing that produces a
          &ldquo;{FEN_PROV_CAD}&rdquo; profile.</b> Drop a DXF or an SVG into the importer and the library
          fills itself from the geometry in the file.
          <br /><br />
          <b>Where that file has not arrived, a section can be entered by hand</b> — typed as coordinates
          off the manufacturer&rsquo;s own dimensioned drawing, or drawn on the canvas. That is the same
          published data entered by a person instead of a parser, and it is how a detailer has always
          worked, so it is available here and it is tagged
          &ldquo;{FEN_PROV_DRAWING}&rdquo; wherever it appears. A working placeholder can also be marked
          &ldquo;{FEN_PROV_APPROX}&rdquo; so an assembly can be laid out before the real section arrives.
          <br /><br />
          What is still refused is <b>guessing a cross-section from a photograph or a datasheet picture</b>:
          it would look right, measure wrong, and every detail, cut length and take-off drawn from it would
          inherit that error silently. A hand-entered profile is superseded in place the moment the real
          CAD lands, keeping its id so nothing that referenced it is orphaned.
        </FenGap>
      ) : (
        <>
          <div className="flex items-center gap-2 flex-wrap">
            <TextInput className="!w-64" value={q} onChange={e => setQ(e.target.value)} placeholder="Search code, name, category, source…" />
            <Select className="!w-64" value={prov} onChange={e => setProv(e.target.value)}>
              <option value="">Any provenance</option>
              {FEN_PROVENANCES.concat([FEN_PROV_UNKNOWN]).map(p => <option key={p} value={p}>{p}</option>)}
            </Select>
            <span className="text-xs text-[var(--leon-black)]/50">
              {shown.length} of {lib.profiles.length} · {verified} verified CAD · {hand} entered by hand
            </span>
          </div>
          <FenSessionNotice />
          <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
            {shown.map(p => {
              const g = p.geometry;
              return (
                <div key={p.id}
                  className={`rounded-lg border bg-white p-3 ${fenIsVerifiedCad(p) ? 'border-[var(--leon-line)]' : 'border-amber-300'}`}>
                  <button onClick={() => setOpen(p)} className="text-left w-full">
                    <div className="bg-[var(--leon-cream)] rounded mb-2 h-40 grid place-items-center overflow-hidden">
                      <FenProfileView geometry={g} height={150} showDims={false} />
                    </div>
                    <div className="flex items-center gap-2 flex-wrap">
                      <span className="font-bold text-sm">{p.code || 'No code'}</span>
                      <span className="ml-auto">
                        <Badge tone={g ? FEN_GEO_TONE[g.validation.status] : 'yellow'}>
                          {g ? g.validation.status : FEN_AWAITING_CAD}
                        </Badge>
                      </span>
                    </div>
                    <div className="mt-1"><FenProvenanceTag profile={p} /></div>
                    <div className="text-xs text-[var(--leon-black)]/55 mt-1">{p.name}</div>
                    <div className="text-[11px] text-[var(--leon-black)]/45">
                      {p.category}
                      {g ? ` · ${g.bounds.width.toFixed(1)} × ${g.bounds.depth.toFixed(1)} mm` : ''}
                    </div>
                    {!fenIsVerifiedCad(p) && (
                      <div className="text-[11px] text-amber-800 mt-1">
                        {p.sourceDocName ? `From ${p.sourceDocName}` : 'No source document named'}
                        {p.tracedFromImage ? ' · traced over an image' : ''}
                      </div>
                    )}
                  </button>
                  {editable && (
                    <div className="flex gap-3 mt-2 pt-2 border-t border-[var(--leon-line)] text-[11px] font-semibold">
                      <button className="text-[var(--leon-brown)]" onClick={() => setEditing({ base: p, dup: false })}>Edit</button>
                      <button className="text-[var(--leon-brown)]" onClick={() => setEditing({ base: p, dup: true })}>Duplicate</button>
                    </div>
                  )}
                </div>
              );
            })}
          </div>
          {!shown.length && <EmptyState text="No profile matches that search." />}
        </>
      )}

      <FenProfileModal profile={open} onClose={() => setOpen(null)} lib={lib} editable={editable}
        onEdit={p => { setOpen(null); setEditing({ base: p, dup: false }); }}
        onDuplicate={p => { setOpen(null); setEditing({ base: p, dup: true }); }} />
    </div>
  );
}

function FenProfileModal({ profile, onClose, lib, editable, onEdit, onDuplicate }) {
  const [zoom, setZoom] = useState(1);
  if (!profile) return null;
  const g = profile.geometry;
  const sys = fenSystemById(profile.systemId);
  const prov = fenProvenanceOf(profile);
  const upd = f => fenLibraryUpdate(l => fenApplySystemStatuses({ ...l, profiles: l.profiles.map(p => p.id === profile.id ? { ...p, ...f } : p) }));
  return (
    <Modal open={!!profile} onClose={onClose} wide title={`${profile.code || 'Profile'} — ${profile.name}`}
      footer={
        <>
          {editable && onDuplicate && <Button variant="ghost" onClick={() => onDuplicate(profile)}>Duplicate</Button>}
          {editable && onEdit && <Button variant="ghost" onClick={() => onEdit(profile)}>Edit the section</Button>}
          <Button onClick={onClose}>Close</Button>
        </>
      }>
      <div className="space-y-3">
        {/* Provenance is the first thing on the record, because it is the first
            thing anyone reading a section has to know about it. */}
        <div className={`rounded-lg border px-3 py-2 ${prov === FEN_PROV_CAD ? 'border-green-200 bg-green-50' : prov === FEN_PROV_APPROX ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50'}`}>
          <div className="flex items-center gap-2 flex-wrap">
            <FenProvenanceTag profile={profile} full title={false} />
            {profile.sourceDocName && <span className="text-xs font-semibold">from {profile.sourceDocName}</span>}
            {profile.tracedFromImage && <Badge tone="yellow">Traced over an image</Badge>}
          </div>
          <p className="text-xs mt-1 text-[var(--leon-black)]/70">{FEN_PROV_MEANING[prov]}</p>
          {profile.sourceDocFile && (
            <div className="mt-1 text-xs">
              <FileField name={profile.sourceDocFile} url={profile.sourceDocUrl} editable={false}
                label={`${profile.code} source document`} />
            </div>
          )}
        </div>
        <div className="flex items-center gap-2">
          <span className="text-xs text-[var(--leon-black)]/50">Zoom</span>
          <input type="range" min="0.4" max="4" step="0.1" value={zoom} onChange={e => setZoom(Number(e.target.value))} />
          <Button size="sm" variant="ghost" onClick={() => setZoom(1)}>Fit</Button>
          {g && <span className="ml-auto text-xs text-[var(--leon-black)]/50">
            {g.bounds.width.toFixed(1)} × {g.bounds.depth.toFixed(1)} mm
          </span>}
        </div>
        <div className="bg-[var(--leon-cream)] rounded p-3 overflow-auto grid place-items-center" style={{ maxHeight: 420 }}>
          <FenProfileView geometry={g} height={360} zoom={zoom} showDims showOrientation />
        </div>
        {g ? (
          <>
            <FenIssueList issues={g.validation.issues} />
            <table className="w-full text-sm">
              <tbody>
                {[
                  ['System', sys ? sys.name : '—'],
                  ['Category', profile.category],
                  ['Bounding box', `${g.bounds.width.toFixed(2)} × ${g.bounds.depth.toFixed(2)} mm`],
                  ['Centroid', `${g.centroid.x.toFixed(2)}, ${g.centroid.y.toFixed(2)} mm`],
                  ['Entities', `${g.entities.length}`],
                  ['Closed loops', `${(g.closedLoops || []).length} — ${(g.closedLoops || []).filter(l => l.kind === 'Chamber').length} internal chamber(s)`],
                  ['Units in the file', `${g.sourceUnits || 'not declared'} (${g.sourceUnitsConfidence})`],
                  ['Units used', `${(FEN_UNIT_CHOICES.find(u => u.key === g.unitsUsed) || {}).label || '—'}`],
                  ['Confirmed by', g.unitsConfirmedBy ? `${g.unitsConfirmedBy} on ${fmtDate(g.unitsConfirmedDate)}` : 'not confirmed'],
                  ['Source', g.sourceFormat === 'manual'
                    ? `Typed by hand${g.sourceFile && g.sourceFile !== 'typed by hand' ? ` from ${g.sourceFile}` : ' — no source document named'}${profile.enteredBy ? ` · ${profile.enteredBy} on ${fmtDate(profile.enteredDate)}` : ''}`
                    : `${g.sourceFile} · ${String(g.sourceFormat || '').toUpperCase()} · ${Math.round(g.sourceBytes / 1024)} KB`],
                  ['Fingerprint', `${g.sourceHash} (FNV-1a — a change detector, not a cryptographic hash)`],
                  ['Orientation', `interior ${g.orientation.interiorDir} · exterior ${g.orientation.exteriorDir} · glazing ${g.orientation.glazingDir}`],
                  ['Wall thickness', profile.wallThicknessMm ? `${profile.wallThicknessMm} mm` : 'not stated'],
                  ['Glazing pocket', profile.glazingPocketMm ? `${profile.glazingPocketMm} mm` : 'not stated'],
                  ['Thermal break', profile.thermalBreak || 'Not stated'],
                  ['Weight per metre', profile.weightPerMetreKg ? `${profile.weightPerMetreKg} kg/m` : 'not stated'],
                  ['Finish options', profile.finishOptions || 'not stated'],
                ].map(([k, v]) => (
                  <tr key={k} className="border-b border-[var(--leon-line)]/60">
                    <td className="py-1.5 text-[var(--leon-black)]/50 w-48">{k}</td>
                    <td className="py-1.5 font-medium">{v}</td>
                  </tr>
                ))}
              </tbody>
            </table>
            {!!(g.unresolved || []).length && (
              <Collapsible id={`fen-unres-${profile.id}`} title="Read but not resolved" count={g.unresolved.length}>
                <ul className="text-xs space-y-1">
                  {g.unresolved.map((u, i) => <li key={i}><b>{u.type}</b> — {u.reason}</li>)}
                </ul>
              </Collapsible>
            )}
            {!!(g.annotations || []).length && (
              <div className="text-xs text-[var(--leon-black)]/45">
                Ignored on purpose: {g.annotations.map(a => `${a.count}× ${a.type}`).join(', ')} — annotation, not section geometry.
              </div>
            )}
            {!!(profile.provenanceHistory || []).length && (
              <Collapsible id={`fen-prov-${profile.id}`} title="Provenance history" count={profile.provenanceHistory.length}>
                <ul className="text-xs space-y-1.5">
                  {profile.provenanceHistory.slice().reverse().map((h, i) => (
                    <li key={i}>
                      <b>{fmtDate(h.date)}</b> — {h.from} → {h.to}
                      {h.by ? ` · ${h.by}` : ''}
                      <div className="text-[var(--leon-black)]/55">{h.note}</div>
                    </li>
                  ))}
                </ul>
              </Collapsible>
            )}
            <div className="text-xs text-[var(--leon-black)]/45">
              {g.sourceFormat === 'manual'
                ? 'The coordinates typed here are the source, and they are fingerprinted like a file would be. The drawing above is generated from them and is not the source of anything.'
                : 'The original file is kept on this record. The drawing above is generated from the stored coordinates and is not the source of anything.'}
            </div>
          </>
        ) : (
          <p className="text-sm text-[var(--leon-black)]/60">
            This profile has no geometry on file. It is a placeholder for a section that has not been
            supplied — nothing has been sketched in to stand in for it. Either import the
            manufacturer&rsquo;s CAD, or enter the section by hand off their dimensioned drawing, which is
            tagged as such everywhere it appears.
          </p>
        )}
        {editable && (
          <div className="grid grid-cols-2 gap-3 pt-2 border-t border-[var(--leon-line)]">
            <Field label="Code"><TextInput value={profile.code} onChange={e => upd({ code: e.target.value })} /></Field>
            <Field label="Name"><TextInput value={profile.name} onChange={e => upd({ name: e.target.value })} /></Field>
            <Field label="Category">
              <Select value={profile.category} onChange={e => upd({ category: e.target.value })}>
                {FEN_PROFILE_CATEGORIES.map(c => <option key={c}>{c}</option>)}
              </Select>
            </Field>
            <Field label="System">
              <Select value={profile.systemId || ''} onChange={e => upd({ systemId: e.target.value || null })}>
                <option value="">— none —</option>
                {lib.systems.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
              </Select>
            </Field>
          </div>
        )}
      </div>
    </Modal>
  );
}

// ═══════════════════════════════════════════════ Hand entry — the editor
// Two ways in, one record out. The coordinate table is for a person reading a
// dimensioned section and typing what it says; the canvas is for a person who
// would rather draw it. Both write the same `loops` array, both go through
// fenBuildManualGeometry, and neither can produce a Verified CAD profile.

const FEN_CANVAS_W = 780;
const FEN_CANVAS_H = 470;
// A ladder rather than a free number: the grid is a drafting aid, and 1, 2, 5,
// 10 is how a section is actually dimensioned.
const FEN_GRID_STEPS = [0, 0.5, 1, 2, 5, 10];
const FEN_GRID_LADDER = [0.5, 1, 2, 5, 10, 20, 25, 50, 100, 200];
const FEN_DRAW_MODES = [
  { key: 'place', label: '✚ Place points', hint: 'Click to add a point to the active loop. Click its first point again to close it.' },
  { key: 'edit', label: '✥ Move / delete', hint: 'Drag a point to move it. Click one and press Delete, or use the button.' },
  { key: 'pan', label: '✋ Pan', hint: 'Drag to move the view. Nothing on the section is changed.' },
  { key: 'calibrate', label: '📐 Set image scale', hint: 'Drag along a dimension whose length is printed on the drawing, then type that length.' },
];

// A stable signature of the authored geometry, used for one question only: has
// the geometry been touched since the editor opened? That decides whether a
// Verified CAD profile is about to stop being one.
function fenLoopSignature(loops) {
  return JSON.stringify((loops || []).map(l => [l.kind, (l.points || []).map(p => [Number(p.x) || 0, Number(p.y) || 0])]));
}

function fenEditorInitialFields(base, dup) {
  const b = base || {};
  const isCad = !!base && fenIsVerifiedCad(base);
  return {
    code: dup ? `${b.code || 'PROFILE'}-COPY` : (b.code || ''),
    name: dup ? `${b.name || 'Profile'} (copy)` : (b.name || ''),
    category: b.category || 'Uncategorised',
    systemId: b.systemId || '',
    material: b.material || '',
    colour: b.colour || '',
    notes: b.notes || '',
    wallThicknessMm: b.wallThicknessMm === undefined ? null : b.wallThicknessMm,
    glazingPocketMm: b.glazingPocketMm === undefined ? null : b.glazingPocketMm,
    weightPerMetreKg: b.weightPerMetreKg === undefined ? null : b.weightPerMetreKg,
    thermalBreak: b.thermalBreak || 'Not stated',
    finishOptions: b.finishOptions || '',
    // A copy of a verified profile is not itself verified — the moment it can be
    // edited it is a different section from the one in the manufacturer's file.
    provenance: (!dup && isCad) ? FEN_PROV_CAD
      : (!dup && b.provenance) ? b.provenance : FEN_PROV_DRAWING,
    sourceDocName: dup ? '' : (b.sourceDocName || ''),
    sourceDocFile: dup ? '' : (b.sourceDocFile || ''),
    sourceDocUrl: dup ? '' : (b.sourceDocUrl || ''),
    tracedFromImage: dup ? false : !!b.tracedFromImage,
  };
}

// ── The coordinate table ──────────────────────────────────────────────────
function FenCoordTable({ loops, activeId, setActiveId, change, apply, push, editable }) {
  const [pasteFor, setPasteFor] = useState(null);
  const [pasteText, setPasteText] = useState('');
  const [pasteMode, setPasteMode] = useState('replace');
  const [pasteResult, setPasteResult] = useState(null);

  const setKind = (id, kind) => change(ls => ls.map(l => (l.id === id ? { ...l, kind } : l)));
  // Typing is not undo-worthy keystroke by keystroke, so the snapshot is taken
  // when the field is entered and the keystrokes themselves just apply.
  const setPoint = (id, i, axis, v) => apply(ls => ls.map(l => (l.id === id
    ? { ...l, points: l.points.map((p, k) => (k === i ? { ...p, [axis]: v } : p)) } : l)));
  const insertPoint = (id, at) => change(ls => ls.map(l => {
    if (l.id !== id) return l;
    const pts = l.points.slice();
    const near = pts[at - 1] || pts[0] || { x: 0, y: 0 };
    pts.splice(at, 0, { x: Number(near.x) || 0, y: Number(near.y) || 0 });
    return { ...l, points: pts };
  }));
  const delPoint = (id, i) => change(ls => ls.map(l => (l.id === id ? { ...l, points: l.points.filter((p, k) => k !== i) } : l)));
  const movePoint = (id, i, d) => change(ls => ls.map(l => {
    if (l.id !== id) return l;
    const j = i + d;
    if (j < 0 || j >= l.points.length) return l;
    const pts = l.points.slice();
    const t = pts[i]; pts[i] = pts[j]; pts[j] = t;
    return { ...l, points: pts };
  }));
  const addLoop = kind => {
    const l = makeFenManualLoop({ kind });
    change(ls => [...ls, l]);
    setActiveId(l.id);
  };
  // Clearing the active loop matters: an activeId pointing at a loop that no
  // longer exists silently stops the canvas accepting points.
  const removeLoop = id => { change(ls => ls.filter(l => l.id !== id)); if (id === activeId) setActiveId(null); };

  function openPaste(id) { setPasteFor(id); setPasteText(''); setPasteResult(null); setPasteMode('replace'); }
  function applyPaste() {
    const res = fenParsePointText(pasteText);
    setPasteResult(res);
    if (!res.points.length) return;
    change(ls => ls.map(l => (l.id === pasteFor
      ? { ...l, points: pasteMode === 'replace' ? res.points : [...l.points, ...res.points] } : l)));
  }

  return (
    <div className="space-y-3">
      <p className="text-sm text-[var(--leon-black)]/60 max-w-3xl">
        Coordinates are <b>millimetres</b>, X across the section and Y up it, and the loop closes from the
        last point back to the first — you do not repeat the first point at the end. One loop is the
        <b> outer boundary</b>; every other loop is a <b>chamber or void</b> punched out of it.
      </p>
      <div className="flex gap-2 flex-wrap">
        {editable && <Button size="sm" variant="ghost" onClick={() => addLoop('Outer boundary')}>+ Outer boundary</Button>}
        {editable && <Button size="sm" variant="ghost" onClick={() => addLoop('Chamber')}>+ Chamber / void</Button>}
      </div>

      {!loops.length && (
        <div className="text-sm text-[var(--leon-black)]/50 border border-dashed border-[var(--leon-line)] rounded-lg p-4">
          No loops yet. Add the outer boundary, then paste or type its points.
        </div>
      )}

      {loops.map((l, li) => {
        const pts = l.points || [];
        const clean = pts.map(p => ({ x: Number(p.x) || 0, y: Number(p.y) || 0 }));
        const area = clean.length > 2 ? Math.abs(fenPolygonArea(clean)) : 0;
        const b = clean.length ? fenComputeBounds([{ type: 'POLYLINE', points: clean, closed: true }]) : null;
        const isActive = l.id === activeId;
        return (
          <div key={l.id}
            className={`rounded-lg border bg-white p-3 space-y-2 ${isActive ? 'border-[var(--leon-brown)]' : 'border-[var(--leon-line)]'}`}>
            <div className="flex items-center gap-2 flex-wrap text-xs">
              <button onClick={() => setActiveId(l.id)}
                className={`font-bold ${isActive ? 'text-[var(--leon-brown)]' : ''}`}>
                {isActive ? '● ' : '○ '}Loop {li + 1}
              </button>
              <select value={l.kind} disabled={!editable} onChange={e => setKind(l.id, e.target.value)}
                className="px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
                <option value="Outer boundary">Outer boundary</option>
                <option value="Chamber">Chamber / void</option>
              </select>
              <span className="text-[var(--leon-black)]/45">
                {pts.length} point{pts.length === 1 ? '' : 's'}
                {b ? ` · ${b.width.toFixed(1)} × ${b.depth.toFixed(1)} mm` : ''}
                {area ? ` · ${(area / 100).toFixed(2)} cm²` : ''}
              </span>
              <div className="ml-auto flex gap-2">
                {editable && <button onClick={() => openPaste(l.id)} className="font-semibold text-[var(--leon-brown)]">Paste points…</button>}
                {editable && <button onClick={() => insertPoint(l.id, pts.length)} className="font-semibold text-[var(--leon-brown)]">+ Point</button>}
                {editable && <button onClick={() => { if (confirm('Remove this loop and all of its points?')) removeLoop(l.id); }} className="text-red-600">Remove loop</button>}
              </div>
            </div>

            {!!pts.length && (
              <div className="overflow-x-auto">
                <table className="w-full text-xs min-w-[420px]">
                  <thead>
                    <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                      <th className="px-1 py-1 w-10">#</th>
                      <th className="px-1 py-1">X (mm)</th>
                      <th className="px-1 py-1">Y (mm)</th>
                      <th className="px-1 py-1 w-28"></th>
                    </tr>
                  </thead>
                  <tbody>
                    {pts.map((p, i) => (
                      <tr key={i} className="border-b border-[var(--leon-line)]/50">
                        <td className="px-1 py-1 text-[var(--leon-black)]/40 tabular-nums">{i + 1}</td>
                        {['x', 'y'].map(axis => (
                          <td key={axis} className="px-1 py-1">
                            <input value={p[axis] === null || p[axis] === undefined ? '' : p[axis]}
                              disabled={!editable} inputMode="decimal"
                              onFocus={push}
                              onChange={e => setPoint(l.id, i, axis, e.target.value)}
                              className="w-24 px-1 py-0.5 tabular-nums border border-[var(--leon-line)] rounded" />
                          </td>
                        ))}
                        <td className="px-1 py-1">
                          {editable && (
                            <div className="flex gap-1.5 text-[var(--leon-black)]/55">
                              <button onClick={() => movePoint(l.id, i, -1)} title="Move up" disabled={i === 0}>↑</button>
                              <button onClick={() => movePoint(l.id, i, 1)} title="Move down" disabled={i === pts.length - 1}>↓</button>
                              <button onClick={() => insertPoint(l.id, i + 1)} title="Insert a point below">＋</button>
                              <button onClick={() => delPoint(l.id, i)} title="Delete this point" className="text-red-600">✕</button>
                            </div>
                          )}
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            )}
          </div>
        );
      })}

      <Modal open={!!pasteFor} onClose={() => setPasteFor(null)} wide title="Paste a point list"
        footer={
          <>
            <Button variant="ghost" onClick={() => setPasteFor(null)}>Close</Button>
            <Button onClick={applyPaste} disabled={!pasteText.trim()}>
              {pasteMode === 'replace' ? 'Replace the points' : 'Append the points'}
            </Button>
          </>
        }>
        <div className="space-y-3">
          <p className="text-sm text-[var(--leon-black)]/60">
            One point per line, X then Y, separated by a space, a comma or a tab — which covers a copy out
            of a PDF, a spreadsheet or a text file. Several points on one line are read as pairs. A
            row-number column is dropped only when every line has one and it genuinely counts, because
            guessing it on a mixed paste would eat a real X value.
          </p>
          <TextArea rows={10} value={pasteText} onChange={e => setPasteText(e.target.value)}
            placeholder={'0, 0\n0, 86\n60, 86\n60, 0'} className="font-mono text-xs" />
          <div className="flex items-center gap-3 text-xs">
            <label className="flex items-center gap-1">
              <input type="radio" checked={pasteMode === 'replace'} onChange={() => setPasteMode('replace')} />
              Replace this loop&rsquo;s points
            </label>
            <label className="flex items-center gap-1">
              <input type="radio" checked={pasteMode === 'append'} onChange={() => setPasteMode('append')} />
              Append to them
            </label>
          </div>
          {pasteResult && (
            <div className="space-y-1.5">
              <div className="text-xs font-semibold">
                {pasteResult.points.length} point{pasteResult.points.length === 1 ? '' : 's'} read
                {pasteResult.droppedIndexColumn ? ' · a leading row-number column was dropped' : ''}
              </div>
              {!!pasteResult.errors.length && (
                <FenIssueList issues={pasteResult.errors.map(m => ({ level: 'warn', msg: m }))} />
              )}
            </div>
          )}
        </div>
      </Modal>
    </div>
  );
}

// ── The drawing canvas ────────────────────────────────────────────────────
// An SVG, not a canvas element, for the same reason every other drawing in this
// app is an SVG: the picture is generated from the numbers, so the numbers stay
// the record and the picture stays disposable.
function FenDrawCanvas({ loops, activeId, setActiveId, change, apply, push, undo, canUndo, editable, bg, setBg }) {
  const [mode, setMode] = useState('place');
  const [grid, setGrid] = useState(1);
  const [ortho, setOrtho] = useState(true);
  const [view, setView] = useState({ x: -20, y: 120, z: 3 });   // mm at the left edge, mm at the top edge, px per mm
  const [cursor, setCursor] = useState(null);
  const [sel, setSel] = useState(null);
  const [drag, setDrag] = useState(null);
  const [calib, setCalib] = useState(null);
  const svgRef = useRef(null);
  const bgInput = useRef(null);

  const active = loops.find(l => l.id === activeId) || null;
  const toS = p => ({ x: ((Number(p.x) || 0) - view.x) * view.z, y: (view.y - (Number(p.y) || 0)) * view.z });
  const toMm = (sx, sy) => ({ x: view.x + sx / view.z, y: view.y - sy / view.z });
  const snap = v => (grid > 0 ? Math.round(v / grid) * grid : Math.round(v * 100) / 100);

  // The svg is sized by its viewBox and stretched to the column, so a screen
  // coordinate has to be scaled back into viewBox space before it means
  // anything in millimetres.
  function at(e) {
    const el = svgRef.current;
    if (!el) return { x: 0, y: 0 };
    const r = el.getBoundingClientRect();
    if (!r.width || !r.height) return { x: 0, y: 0 };
    return { x: (e.clientX - r.left) * (FEN_CANVAS_W / r.width), y: (e.clientY - r.top) * (FEN_CANVAS_H / r.height) };
  }

  function constrained(m, last) {
    const p = { x: snap(m.x), y: snap(m.y) };
    if (!ortho || !last) return p;
    // Orthogonal: the longer run wins, which is how a detailer draws a rebate.
    const lx = Number(last.x) || 0, ly = Number(last.y) || 0;
    if (Math.abs(p.x - lx) >= Math.abs(p.y - ly)) return { x: p.x, y: ly };
    return { x: lx, y: p.y };
  }

  function hitPoint(s) {
    let best = null;
    loops.forEach(l => (l.points || []).forEach((p, i) => {
      const q = toS(p);
      const d = Math.sqrt((q.x - s.x) * (q.x - s.x) + (q.y - s.y) * (q.y - s.y));
      if (d <= 10 && (!best || d < best.d)) best = { loopId: l.id, index: i, d };
    }));
    return best;
  }

  function place(s) {
    if (!active) return;
    const pts = active.points || [];
    const last = pts[pts.length - 1];
    // Clicking the first point again closes the loop — the gesture everybody
    // already knows from every drafting tool.
    if (pts.length >= 3) {
      const f = toS(pts[0]);
      if (Math.sqrt((f.x - s.x) * (f.x - s.x) + (f.y - s.y) * (f.y - s.y)) <= 9) { setActiveId(null); return; }
    }
    const m = constrained(toMm(s.x, s.y), last);
    change(ls => ls.map(l => (l.id === active.id ? { ...l, points: [...l.points, m] } : l)));
  }

  function onDown(e) {
    if (!editable) return;
    const s = at(e);
    if (mode === 'pan') { setDrag({ kind: 'pan', s, view }); return; }
    if (mode === 'calibrate') { const m = toMm(s.x, s.y); setCalib({ a: m, b: m, known: '' }); setDrag({ kind: 'calib' }); return; }
    if (mode === 'edit') {
      const hit = hitPoint(s);
      if (hit) { setSel({ loopId: hit.loopId, index: hit.index }); setActiveId(hit.loopId); push(); setDrag({ kind: 'point', loopId: hit.loopId, index: hit.index }); }
      else setSel(null);
      return;
    }
    if (mode === 'place') place(s);
  }
  function onMove(e) {
    const s = at(e);
    setCursor(s);
    if (!drag) return;
    if (drag.kind === 'pan') {
      setView({ ...drag.view, x: drag.view.x - (s.x - drag.s.x) / drag.view.z, y: drag.view.y + (s.y - drag.s.y) / drag.view.z });
      return;
    }
    if (drag.kind === 'calib') { const m = toMm(s.x, s.y); setCalib(c => (c ? { ...c, b: m } : c)); return; }
    if (drag.kind === 'point') {
      const m = { x: snap(toMm(s.x, s.y).x), y: snap(toMm(s.x, s.y).y) };
      apply(ls => ls.map(l => (l.id === drag.loopId
        ? { ...l, points: l.points.map((p, i) => (i === drag.index ? m : p)) } : l)));
    }
  }
  function onUp() { setDrag(null); }

  function zoomBy(f) {
    setView(v => {
      const cx = v.x + (FEN_CANVAS_W / 2) / v.z, cy = v.y - (FEN_CANVAS_H / 2) / v.z;
      const z = Math.max(0.3, Math.min(60, v.z * f));
      return { x: cx - (FEN_CANVAS_W / 2) / z, y: cy + (FEN_CANVAS_H / 2) / z, z };
    });
  }
  function fit() {
    const ents = fenManualEntities(loops);
    let b = fenComputeBounds(ents);
    if (bg && bg.url) {
      const minX = ents.length ? Math.min(b.minX, bg.x) : bg.x;
      const minY = ents.length ? Math.min(b.minY, bg.y) : bg.y;
      const maxX = ents.length ? Math.max(b.maxX, bg.x + bg.widthMm) : bg.x + bg.widthMm;
      const maxY = ents.length ? Math.max(b.maxY, bg.y + bg.heightMm) : bg.y + bg.heightMm;
      b = { minX, minY, maxX, maxY, width: maxX - minX, depth: maxY - minY };
    }
    if (!(b.width > 0) && !(b.depth > 0)) { setView({ x: -20, y: 120, z: 3 }); return; }
    const pad = Math.max(6, Math.max(b.width, b.depth) * 0.12);
    const z = Math.min(FEN_CANVAS_W / (b.width + pad * 2), FEN_CANVAS_H / (b.depth + pad * 2));
    setView({ x: b.minX - pad, y: b.maxY + pad, z: Math.max(0.3, Math.min(60, z)) });
  }

  function deleteSelected() {
    if (!sel) return;
    change(ls => ls.map(l => (l.id === sel.loopId ? { ...l, points: l.points.filter((p, i) => i !== sel.index) } : l)));
    setSel(null);
  }
  function onKey(e) {
    if (!editable) return;
    if ((e.key === 'Delete' || e.key === 'Backspace') && sel) { e.preventDefault(); deleteSelected(); return; }
    if (e.key === 'Escape') { setSel(null); setCalib(null); return; }
    if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); undo(); return; }
    if (e.key === 'Enter' && mode === 'place') { e.preventDefault(); setActiveId(null); }
  }

  async function loadBg(file) {
    if (!file) return;
    const url = await readFileAsDataURL(file);
    const img = new Image();
    img.onload = () => {
      // Placed at a nominal scale, because a picture does not know how big it
      // is. Calibrating against a printed dimension is the next step, and the
      // panel says so until it has been done.
      const mmPerPx = 0.25;
      setBg({ url, name: file.name, x: 0, y: 0,
        widthMm: (img.naturalWidth || 800) * mmPerPx, heightMm: (img.naturalHeight || 600) * mmPerPx,
        opacity: 0.55, calibrated: false });
    };
    img.src = url;
  }
  function applyCalibration() {
    if (!bg || !calib) return;
    const known = Number(calib.known);
    const measured = Math.sqrt((calib.b.x - calib.a.x) * (calib.b.x - calib.a.x) + (calib.b.y - calib.a.y) * (calib.b.y - calib.a.y));
    if (!isFinite(known) || known <= 0 || !(measured > 0)) return;
    // Scaling the placement about the image's own origin scales every distance
    // measured on it by exactly the same factor, which is why one dimension is
    // enough.
    const f = known / measured;
    setBg({ ...bg, widthMm: bg.widthMm * f, heightMm: bg.heightMm * f, calibrated: true, calibratedAgainst: known });
    setCalib(null);
    setMode('place');
  }

  // ── the picture ──
  const step = FEN_GRID_LADDER.find(s => s >= (grid || 1) && s * view.z >= 7) || 200;
  const gridEls = [];
  if (grid > 0) {
    const xEnd = view.x + FEN_CANVAS_W / view.z;
    const yBot = view.y - FEN_CANVAS_H / view.z;
    for (let x = Math.ceil(view.x / step) * step; x <= xEnd && gridEls.length < 400; x += step) {
      const sx = (x - view.x) * view.z;
      gridEls.push(<line key={`gx${x}`} x1={sx} y1={0} x2={sx} y2={FEN_CANVAS_H}
        stroke="var(--leon-line)" strokeWidth={Math.abs(x) < 1e-9 ? 1.3 : 0.5} opacity={Math.abs(x) < 1e-9 ? 0.9 : 0.55} />);
    }
    for (let y = Math.ceil(yBot / step) * step; y <= view.y && gridEls.length < 800; y += step) {
      const sy = (view.y - y) * view.z;
      gridEls.push(<line key={`gy${y}`} x1={0} y1={sy} x2={FEN_CANVAS_W} y2={sy}
        stroke="var(--leon-line)" strokeWidth={Math.abs(y) < 1e-9 ? 1.3 : 0.5} opacity={Math.abs(y) < 1e-9 ? 0.9 : 0.55} />);
    }
  }
  const pathOf = l => (l.points || []).map((p, i) => {
    const q = toS(p);
    return `${i ? 'L' : 'M'} ${q.x.toFixed(1)} ${q.y.toFixed(1)}`;
  }).join(' ') + ((l.points || []).length > 2 ? ' Z' : '');

  const lastPt = active && active.points.length ? active.points[active.points.length - 1] : null;
  const ghost = (mode === 'place' && cursor && lastPt) ? constrained(toMm(cursor.x, cursor.y), lastPt) : null;
  const modeHint = (FEN_DRAW_MODES.find(m => m.key === mode) || {}).hint || '';
  const calibLen = calib ? Math.sqrt((calib.b.x - calib.a.x) * (calib.b.x - calib.a.x) + (calib.b.y - calib.a.y) * (calib.b.y - calib.a.y)) : 0;

  return (
    <div className="space-y-2">
      <div className="flex items-center gap-2 flex-wrap text-xs">
        {FEN_DRAW_MODES.map(m => (
          <button key={m.key} onClick={() => setMode(m.key)} disabled={!editable || (m.key === 'calibrate' && !(bg && bg.url))}
            className={`px-2 py-1 rounded font-semibold border ${mode === m.key ? 'border-[var(--leon-brown)] text-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] text-[var(--leon-black)]/60'} disabled:opacity-35`}>
            {m.label}
          </button>
        ))}
        <span className="ml-2">Snap</span>
        <select value={grid} onChange={e => setGrid(Number(e.target.value))}
          className="px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
          {FEN_GRID_STEPS.map(s => <option key={s} value={s}>{s === 0 ? 'Off' : `${s} mm`}</option>)}
        </select>
        <label className="flex items-center gap-1 font-semibold">
          <input type="checkbox" checked={ortho} onChange={e => setOrtho(e.target.checked)} /> Orthogonal
        </label>
        <div className="ml-auto flex items-center gap-1.5">
          <Button size="sm" variant="ghost" onClick={undo} disabled={!canUndo}>↶ Undo</Button>
          <Button size="sm" variant="ghost" onClick={() => zoomBy(1 / 1.3)}>−</Button>
          <Button size="sm" variant="ghost" onClick={() => zoomBy(1.3)}>＋</Button>
          <Button size="sm" variant="ghost" onClick={fit}>Fit</Button>
        </div>
      </div>

      <div className="flex items-center gap-2 flex-wrap text-xs">
        <span className="text-[var(--leon-black)]/45">Active loop</span>
        <select value={activeId || ''} onChange={e => setActiveId(e.target.value || null)}
          className="px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
          <option value="">— none, nothing will be added —</option>
          {loops.map((l, i) => <option key={l.id} value={l.id}>Loop {i + 1} · {l.kind} ({(l.points || []).length})</option>)}
        </select>
        {editable && (
          <button className="font-semibold text-[var(--leon-brown)]"
            onClick={() => { const l = makeFenManualLoop({ kind: loops.some(x => x.kind === 'Outer boundary') ? 'Chamber' : 'Outer boundary' }); change(ls => [...ls, l]); setActiveId(l.id); }}>
            + New loop
          </button>
        )}
        {editable && sel && <button className="text-red-600 font-semibold" onClick={deleteSelected}>Delete the selected point</button>}
        <span className="ml-auto text-[var(--leon-black)]/45 tabular-nums">
          {cursor ? `${toMm(cursor.x, cursor.y).x.toFixed(1)}, ${toMm(cursor.x, cursor.y).y.toFixed(1)} mm` : '—'}
        </span>
      </div>

      <div tabIndex={0} onKeyDown={onKey} className="outline-none">
        <svg ref={svgRef} viewBox={`0 0 ${FEN_CANVAS_W} ${FEN_CANVAS_H}`} width="100%"
          className="rounded-lg border border-[var(--leon-line)] bg-white select-none"
          style={{ cursor: mode === 'pan' ? 'grab' : 'crosshair', touchAction: 'none' }}
          onMouseDown={onDown} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={() => { setDrag(null); setCursor(null); }}>
          {bg && bg.url && (
            <image href={bg.url} preserveAspectRatio="none" opacity={bg.opacity}
              x={((bg.x) - view.x) * view.z} y={(view.y - (bg.y + bg.heightMm)) * view.z}
              width={Math.max(1, bg.widthMm * view.z)} height={Math.max(1, bg.heightMm * view.z)}
              style={{ pointerEvents: 'none' }} />
          )}
          {gridEls}
          {loops.map(l => {
            const isActive = l.id === activeId;
            const chamber = l.kind === 'Chamber';
            return (
              <g key={l.id}>
                <path d={pathOf(l)} fill={chamber ? '#fff' : 'var(--leon-brown)'} fillOpacity={chamber ? 0.9 : 0.14}
                  stroke={isActive ? 'var(--leon-brown)' : 'var(--leon-black)'} strokeWidth={isActive ? 1.8 : 1.1}
                  strokeDasharray={chamber ? '4 3' : undefined} />
                {(l.points || []).map((p, i) => {
                  const q = toS(p);
                  const picked = sel && sel.loopId === l.id && sel.index === i;
                  return (
                    <g key={i}>
                      <circle cx={q.x} cy={q.y} r={picked ? 5 : (isActive ? 3.6 : 2.4)}
                        fill={picked ? 'var(--leon-brown)' : (isActive ? '#fff' : 'var(--leon-line)')}
                        stroke={isActive ? 'var(--leon-brown)' : 'var(--leon-black)'} strokeWidth="1.2" />
                      {isActive && i === 0 && <circle cx={q.x} cy={q.y} r="8" fill="none" stroke="var(--leon-brown)" strokeWidth="0.8" opacity="0.6" />}
                    </g>
                  );
                })}
              </g>
            );
          })}
          {ghost && lastPt && (
            <g>
              <line x1={toS(lastPt).x} y1={toS(lastPt).y} x2={toS(ghost).x} y2={toS(ghost).y}
                stroke="var(--leon-brown)" strokeWidth="1" strokeDasharray="4 3" opacity="0.8" />
              <text x={toS(ghost).x + 8} y={toS(ghost).y - 8} fontSize="11" fill="var(--leon-brown)">
                {Math.abs(ghost.x - (Number(lastPt.x) || 0)).toFixed(1)} × {Math.abs(ghost.y - (Number(lastPt.y) || 0)).toFixed(1)} mm
              </text>
            </g>
          )}
          {calib && (
            <g>
              <line x1={toS(calib.a).x} y1={toS(calib.a).y} x2={toS(calib.b).x} y2={toS(calib.b).y}
                stroke="#b83b3b" strokeWidth="1.6" />
              <text x={(toS(calib.a).x + toS(calib.b).x) / 2} y={(toS(calib.a).y + toS(calib.b).y) / 2 - 6}
                fontSize="11" fill="#b83b3b" textAnchor="middle">{calibLen.toFixed(1)} mm at the current scale</text>
            </g>
          )}
        </svg>
      </div>
      <div className="text-[11px] text-[var(--leon-black)]/50">{modeHint} Click a point in the canvas and press Delete to remove it; Ctrl/⌘+Z undoes.</div>

      {/* ── Tracing ── */}
      <Collapsible id="fen-draw-bg" title="Trace over a section image" defaultOpen={!!(bg && bg.url)}>
        <div className="space-y-2">
          <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
            <b>Tracing is an aid, not a source.</b> A picture of a section is not the section — that is the
            one thing this module will not pretend. Tracing over the manufacturer&rsquo;s own dimensioned
            drawing, scaled against a dimension printed on it, produces a profile marked
            &ldquo;{FEN_PROV_DRAWING}&rdquo; at best. It can never produce &ldquo;{FEN_PROV_CAD}&rdquo;, and
            if the image is a photo, a render or an undimensioned catalogue picture, mark the result
            &ldquo;{FEN_PROV_APPROX}&rdquo;.
          </div>
          <div className="flex items-center gap-2 flex-wrap text-xs">
            {editable && (
              <>
                <Button size="sm" variant="ghost" onClick={() => bgInput.current && bgInput.current.click()}>
                  {bg && bg.url ? 'Replace the image' : 'Load a section image'}
                </Button>
                <input ref={bgInput} type="file" accept="image/*" className="hidden"
                  onChange={e => { loadBg(e.target.files && e.target.files[0]); e.target.value = ''; }} />
              </>
            )}
            {bg && bg.url && <>
              <span className="text-[var(--leon-black)]/50">{bg.name}</span>
              <label className="flex items-center gap-1">Opacity
                <input type="range" min="0.1" max="1" step="0.05" value={bg.opacity}
                  onChange={e => setBg({ ...bg, opacity: Number(e.target.value) })} />
              </label>
              <label className="flex items-center gap-1">X
                <input type="number" value={bg.x} onChange={e => setBg({ ...bg, x: Number(e.target.value) || 0 })}
                  className="w-20 px-1 py-0.5 border border-[var(--leon-line)] rounded" />
              </label>
              <label className="flex items-center gap-1">Y
                <input type="number" value={bg.y} onChange={e => setBg({ ...bg, y: Number(e.target.value) || 0 })}
                  className="w-20 px-1 py-0.5 border border-[var(--leon-line)] rounded" />
              </label>
              <button className="text-red-600 font-semibold" onClick={() => { setBg(null); setCalib(null); if (mode === 'calibrate') setMode('place'); }}>Remove</button>
            </>}
          </div>
          {bg && bg.url && (
            <div className={`rounded border px-3 py-2 text-xs ${bg.calibrated ? 'border-green-200 bg-green-50 text-green-800' : 'border-amber-200 bg-amber-50 text-amber-900'}`}>
              {bg.calibrated
                ? `Scale set against a stated ${bg.calibratedAgainst} mm dimension. The image now measures ${bg.widthMm.toFixed(1)} × ${bg.heightMm.toFixed(1)} mm.`
                : 'This image has no scale yet, so anything traced over it is the wrong size. Switch to “Set image scale”, drag along a dimension printed on the drawing, and type its length.'}
            </div>
          )}
          {calib && bg && (
            <div className="flex items-end gap-2 flex-wrap">
              <Field label="That line is really…" hint="In millimetres, off the drawing.">
                <TextInput className="!w-40" value={calib.known} inputMode="decimal"
                  onChange={e => setCalib({ ...calib, known: e.target.value })} placeholder="e.g. 86" />
              </Field>
              <Button size="sm" onClick={applyCalibration} disabled={!(Number(calib.known) > 0) || !(calibLen > 0)}>Set the scale</Button>
              <Button size="sm" variant="ghost" onClick={() => setCalib(null)}>Cancel</Button>
            </div>
          )}
          <p className="text-[11px] text-[var(--leon-black)]/45">
            The image is used for this editing session only and is not stored on the profile. Attach the
            manufacturer document itself under &ldquo;How this section was obtained&rdquo; above — that is
            what makes the profile say where it came from.
          </p>
        </div>
      </Collapsible>
    </div>
  );
}

// ── The profile editor ────────────────────────────────────────────────────
// A panel, not a modal: a coordinate table and a drawing canvas need the width
// of the page, and the alternative is a section typed into a 600-pixel box.
function FenProfileEditor({ ctx, lib, base, dup, editable, onClose, onSaved }) {
  const [f, setF] = useState(() => fenEditorInitialFields(base, dup));
  // Built ONCE. Calling fenLoopsFromGeometry twice would mint two sets of loop
  // ids and the signature would never match the loops it is compared against.
  const [initialLoops] = useState(() => fenLoopsFromGeometry(base ? base.geometry : null));
  const [loops, setLoops] = useState(initialLoops);
  const [initialSig] = useState(() => fenLoopSignature(initialLoops));
  const [undoStack, setUndoStack] = useState([]);
  // Seeded here rather than in an effect: an effect that refills a null active
  // loop would undo the close-the-loop gesture the instant it happened.
  const [activeId, setActiveId] = useState(initialLoops.length ? initialLoops[0].id : null);
  const [bg, setBg] = useState(null);
  const [usedTracing, setUsedTracing] = useState(false);
  const [tab, setTab] = useState('table');
  const [tried, setTried] = useState(false);
  const [confirmDowngrade, setConfirmDowngrade] = useState(false);

  useEffect(() => { if (bg && bg.url) setUsedTracing(true); }, [bg]);

  const pushUndo = () => setUndoStack(u => [...u.slice(-79), loops]);
  const apply = fn => setLoops(prev => fn(prev));
  const change = fn => { pushUndo(); setLoops(prev => fn(prev)); };
  const undo = () => {
    if (!undoStack.length) return;
    setLoops(undoStack[undoStack.length - 1]);
    setUndoStack(undoStack.slice(0, -1));
  };

  const set = patch => setF(prev => ({ ...prev, ...patch }));
  const num = v => (v === '' || v === null || v === undefined ? null : Number(v));

  const startedAsCad = !!base && !dup && fenIsVerifiedCad(base);
  const geometryChanged = fenLoopSignature(loops) !== initialSig;
  // True for a duplicate too: copying a curved section and saving it stores the
  // sampled points, not the radii, and that is worth saying in both cases.
  const hadCurves = !!base && fenGeometryHasCurves(base.geometry);
  // Editing a parsed section by hand replaces the manufacturer's file as the
  // authority for this record. That is allowed and it is never quiet.
  const willDowngrade = startedAsCad && geometryChanged;
  // FEN_PROV_CAD can only survive here when the manufacturer's own geometry is
  // still untouched. Anywhere else it is clamped down to the hand-entered
  // state, so a stale field value can never carry the CAD label past an edit.
  const effectiveProvenance = (startedAsCad && !geometryChanged) ? FEN_PROV_CAD
    : (f.provenance === FEN_PROV_CAD ? FEN_PROV_DRAWING : f.provenance);

  const issues = fenValidateManualLoops(loops, { ...f, provenance: effectiveProvenance });
  const errors = issues.filter(i => i.level === 'error');
  const preview = loops.length ? fenBuildManualGeometry(loops, {
    by: ctx.currentUserName, sourceDocName: f.sourceDocName, tracedFromImage: usedTracing,
  }) : null;

  function save() {
    setTried(true);
    if (errors.length) return;
    if (willDowngrade && !confirmDowngrade) return;
    const keep = (base && !dup) ? base : null;
    const geometry = (startedAsCad && !geometryChanged)
      ? base.geometry
      : fenBuildManualGeometry(loops, {
        by: ctx.currentUserName, sourceDocName: f.sourceDocName, tracedFromImage: usedTracing,
        orientation: (keep && keep.geometry) ? keep.geometry.orientation : undefined,
      });
    const from = keep ? fenProvenanceOf(keep) : null;
    const history = keep ? (keep.provenanceHistory || []).slice() : [];
    if (from && from !== effectiveProvenance) {
      history.push({
        date: todayISO(), from, to: effectiveProvenance, by: ctx.currentUserName,
        note: geometryChanged
          ? 'Geometry edited by hand in the profile editor, so this record is no longer the manufacturer’s parsed file.'
          : 'Provenance changed in the profile editor.',
      });
    }
    const sysId = f.systemId || null;
    const next = makeFenProfile(Object.assign({}, keep || {}, {
      code: f.code.trim(), name: f.name.trim() || f.code.trim(),
      category: f.category, systemId: sysId,
      manufacturerId: (fenSystemById(sysId) || {}).manufacturerId || null,
      material: f.material, colour: f.colour, notes: f.notes,
      wallThicknessMm: num(f.wallThicknessMm), glazingPocketMm: num(f.glazingPocketMm),
      weightPerMetreKg: num(f.weightPerMetreKg), thermalBreak: f.thermalBreak, finishOptions: f.finishOptions,
      geometry, geometryStatus: geometry.validation.status,
      provenance: effectiveProvenance, provenanceHistory: history,
      sourceDocName: f.sourceDocName, sourceDocFile: f.sourceDocFile, sourceDocUrl: f.sourceDocUrl,
      tracedFromImage: usedTracing || (!!f.tracedFromImage && !dup),
      copiedFromProfileId: dup && base ? base.id : (keep ? keep.copiedFromProfileId : null),
      enteredBy: ctx.currentUserName, enteredDate: todayISO(),
      createdBy: keep ? keep.createdBy : ctx.currentUserName,
      createdDate: keep ? keep.createdDate : todayISO(),
    }));
    fenSaveProfile(next);
    onSaved(next);
  }

  function exportPoints() {
    downloadCsv(`${f.code || 'profile'} — coordinates`,
      [{ key: 'loop', label: 'Loop' }, { key: 'kind', label: 'Kind' }, { key: 'i', label: 'Point' },
       { key: 'x', label: 'X (mm)' }, { key: 'y', label: 'Y (mm)' }],
      loops.flatMap((l, li) => (l.points || []).map((p, i) => ({
        loop: li + 1, kind: l.kind, i: i + 1, x: Number(p.x) || 0, y: Number(p.y) || 0,
      }))));
  }

  const title = dup ? `Duplicate ${base ? (base.code || 'profile') : 'profile'}`
    : base ? `Edit ${base.code || 'profile'}` : 'Enter a profile by hand';

  return (
    <div className="space-y-4">
      <div className="flex items-start justify-between gap-3 flex-wrap">
        <div>
          <h3 className="font-bold">{title}</h3>
          <p className="text-sm text-[var(--leon-black)]/55 max-w-3xl">
            Typed coordinates produce exactly the record the importer produces — the same entities, the
            same loop detection, the same bounds, the same validation — so the assembly designer, the
            sections, the BOM and the cut list all read it unchanged. The one thing that differs is the
            provenance, and that travels with the profile everywhere it is shown.
          </p>
        </div>
        <div className="flex gap-2">
          <Button size="sm" variant="ghost" onClick={exportPoints} disabled={!loops.length}>Export the points</Button>
          <Button size="sm" variant="ghost" onClick={onClose}>Cancel</Button>
          <Button size="sm" onClick={save} disabled={!editable}>
            {base && !dup ? 'Save the profile' : 'Add to the library'}
          </Button>
        </div>
      </div>

      {dup && base && (
        <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)] px-3 py-2 text-xs">
          Copied from <b>{base.code} {base.name}</b> (<FenProvenanceTag profile={base} />). A copy you can edit
          is not the file it was copied from, so it starts as a hand-entered record. Give it its own code.
        </div>
      )}
      {startedAsCad && (
        <div className={`rounded-lg border px-3 py-2 text-xs ${geometryChanged ? 'border-red-200 bg-red-50 text-red-800' : 'border-green-200 bg-green-50 text-green-800'}`}>
          {geometryChanged ? (
            <>
              <b>You have edited geometry that came out of the manufacturer&rsquo;s own file.</b> Saving replaces
              it with what is in the editor, and this record stops being &ldquo;{FEN_PROV_CAD}&rdquo;. The
              change is written into the profile&rsquo;s provenance history either way.
              <label className="flex items-center gap-2 mt-1.5 font-semibold">
                <input type="checkbox" checked={confirmDowngrade} onChange={e => setConfirmDowngrade(e.target.checked)} />
                I understand this profile will no longer be verified CAD.
              </label>
            </>
          ) : (
            <>This profile&rsquo;s geometry came from the manufacturer&rsquo;s file and is untouched, so it stays
            &ldquo;{FEN_PROV_CAD}&rdquo;. Editing the name, the category or the published figures does not change that;
            editing a coordinate does.</>
          )}
        </div>
      )}
      {hadCurves && (
        <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
          The section this came from contains arcs. The coordinate table and the canvas hold straight
          segments only, so the curves appear here as the points they were sampled at — saving stores
          those points instead of the radii a fabricator needs.
          {dup
            ? ' A copy made this way is a faceted version of the original, which is a reason to mark it “' + FEN_PROV_APPROX + '” unless you retype the real dimensions.'
            : ' Edit this record’s details rather than its geometry, or re-import the manufacturer’s file.'}
        </div>
      )}

      {/* ── Provenance is first, because it is the question the record answers ── */}
      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-4 space-y-3">
        <div className="flex items-center gap-2 flex-wrap">
          <h4 className="font-bold text-sm">How this section was obtained</h4>
          <FenProvenanceTag provenance={effectiveProvenance} full />
        </div>
        <p className="text-sm text-[var(--leon-black)]/60 max-w-3xl">{FEN_PROV_MEANING[effectiveProvenance]}</p>
        {startedAsCad && !geometryChanged ? (
          <p className="text-xs text-[var(--leon-black)]/50">
            &ldquo;{FEN_PROV_CAD}&rdquo; is set by the importer and by nothing else. It cannot be chosen here.
          </p>
        ) : (
          <>
            <div className="grid gap-3 sm:grid-cols-2">
              <Field label="Provenance" hint={`“${FEN_PROV_CAD}” is set by the importer only — it is not offered here.`}>
                <Select value={effectiveProvenance} disabled={!editable} onChange={e => set({ provenance: e.target.value })}>
                  {FEN_PROV_MANUAL_CHOICES.map(p => <option key={p}>{p}</option>)}
                </Select>
              </Field>
              <Field label="Source document"
                hint="The drawing number, datasheet page or revision these dimensions were read off.">
                <TextInput value={f.sourceDocName} disabled={!editable}
                  placeholder="e.g. GENEO 4700 section sheet, rev. C, p.12"
                  onChange={e => set({ sourceDocName: e.target.value })} />
              </Field>
            </div>
            <Field label="Attach that document" hint="Where it can be attached, attach it — a named source you can open is worth more than a named source you cannot.">
              <FileField name={f.sourceDocFile} url={f.sourceDocUrl} editable={editable}
                label={`${f.code || 'Profile'} source document`}
                placeholder="No source document attached"
                onChange={(nm, url) => set({ sourceDocFile: nm, sourceDocUrl: url })} />
            </Field>
            {usedTracing && (
              <div className="text-xs text-amber-900">
                A background image was used in this session, so this record will be marked as traced. That is
                recorded on the profile and shown wherever it appears.
              </div>
            )}
          </>
        )}
      </div>

      <div className="grid gap-4 xl:grid-cols-[1fr_360px] items-start">
        <div className="space-y-3">
          <div className="rounded-lg border border-[var(--leon-line)] bg-white p-4 space-y-3">
            <h4 className="font-bold text-sm">The profile record</h4>
            <div className="grid gap-3 sm:grid-cols-2">
              <Field label="Profile code" hint="The manufacturer’s own code. Everything downstream refers to it by this.">
                <TextInput value={f.code} disabled={!editable} onChange={e => set({ code: e.target.value })} placeholder="e.g. 470501" />
              </Field>
              <Field label="Name">
                <TextInput value={f.name} disabled={!editable} onChange={e => set({ name: e.target.value })} placeholder="e.g. Outer frame, 86 mm" />
              </Field>
              <Field label="Family / role" hint="Where this extrusion sits in an assembly.">
                <Select value={f.category} disabled={!editable} onChange={e => set({ category: e.target.value })}>
                  {FEN_PROFILE_CATEGORIES.map(c => <option key={c}>{c}</option>)}
                </Select>
              </Field>
              <Field label="System" hint="The manufacturer follows from the system; it is not chosen twice.">
                <Select value={f.systemId || ''} disabled={!editable} onChange={e => set({ systemId: e.target.value })}>
                  <option value="">— none —</option>
                  {lib.systems.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
                </Select>
              </Field>
              <Field label="Material"><TextInput value={f.material} disabled={!editable} onChange={e => set({ material: e.target.value })} placeholder="e.g. PVC-U" /></Field>
              <Field label="Colour"><TextInput value={f.colour} disabled={!editable} onChange={e => set({ colour: e.target.value })} /></Field>
              <Field label="Wall thickness (mm)">
                <TextInput value={f.wallThicknessMm === null ? '' : f.wallThicknessMm} inputMode="decimal" disabled={!editable}
                  onChange={e => set({ wallThicknessMm: e.target.value })} placeholder="—" />
              </Field>
              <Field label="Glazing pocket (mm)" hint="The rebate depth the glass unit sits in.">
                <TextInput value={f.glazingPocketMm === null ? '' : f.glazingPocketMm} inputMode="decimal" disabled={!editable}
                  onChange={e => set({ glazingPocketMm: e.target.value })} placeholder="—" />
              </Field>
              <Field label="Thermal break">
                <Select value={f.thermalBreak} disabled={!editable} onChange={e => set({ thermalBreak: e.target.value })}>
                  {FEN_THERMAL_BREAKS.map(t => <option key={t}>{t}</option>)}
                </Select>
              </Field>
              <Field label="Weight per metre (kg/m)" hint="Used to total the weight of a cut list.">
                <TextInput value={f.weightPerMetreKg === null ? '' : f.weightPerMetreKg} inputMode="decimal" disabled={!editable}
                  onChange={e => set({ weightPerMetreKg: e.target.value })} placeholder="—" />
              </Field>
              <Field label="Finish options" className="sm:col-span-2" hint="As the manufacturer lists them, comma separated.">
                <TextInput value={f.finishOptions} disabled={!editable} onChange={e => set({ finishOptions: e.target.value })}
                  placeholder="e.g. White, Anthracite Grey foil, Golden Oak foil" />
              </Field>
              <Field label="Notes" className="sm:col-span-2">
                <TextArea rows={2} value={f.notes} disabled={!editable} onChange={e => set({ notes: e.target.value })} />
              </Field>
            </div>
            <p className="text-[11px] text-[var(--leon-black)]/45">
              Overall width and depth are not typed here — they are measured off the geometry, and a typed
              copy of a measured number is only a second number to disagree with the first.
            </p>
          </div>

          <div className="rounded-lg border border-[var(--leon-line)] bg-white p-4 space-y-3">
            <div className="flex items-center gap-2 flex-wrap">
              <h4 className="font-bold text-sm">The cross-section</h4>
              <div className="ml-auto flex gap-1">
                {[['table', '⌗ Coordinate table'], ['draw', '✎ Draw it']].map(([k, label]) => (
                  <button key={k} onClick={() => setTab(k)}
                    className={`px-2.5 py-1 text-xs font-semibold rounded border ${tab === k ? 'border-[var(--leon-brown)] text-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] text-[var(--leon-black)]/60'}`}>
                    {label}
                  </button>
                ))}
              </div>
            </div>
            {tab === 'table'
              ? <FenCoordTable loops={loops} activeId={activeId} setActiveId={setActiveId}
                  change={change} apply={apply} push={pushUndo} editable={editable} />
              : <FenDrawCanvas loops={loops} activeId={activeId} setActiveId={setActiveId}
                  change={change} apply={apply} push={pushUndo} undo={undo} canUndo={!!undoStack.length}
                  editable={editable} bg={bg} setBg={setBg} />}
            <p className="text-[11px] text-[var(--leon-black)]/45">
              Both views edit the same points. Switch between them freely — the table is faster off a
              dimension string, the canvas is faster off a shape.
            </p>
          </div>
        </div>

        <div className="space-y-3 xl:sticky xl:top-4">
          <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
            <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">As it will be stored</div>
            <div className="bg-[var(--leon-cream)] rounded p-2 grid place-items-center min-h-[200px]">
              <FenProfileView geometry={preview} height={220} showDims showOrientation />
            </div>
            {preview && (
              <div className="text-xs space-y-1">
                {[['Measured', `${preview.bounds.width.toFixed(1)} × ${preview.bounds.depth.toFixed(1)} mm`],
                  ['Loops', `${(preview.closedLoops || []).length} — ${(preview.closedLoops || []).filter(l => l.kind === 'Chamber').length} chamber(s)`],
                  ['Entities', `${preview.entities.length}`],
                  ['Check', preview.validation.status]].map(([k, v]) => (
                  <div key={k} className="flex justify-between gap-3">
                    <span className="text-[var(--leon-black)]/50">{k}</span>
                    <span className="font-semibold text-right">{v}</span>
                  </div>
                ))}
              </div>
            )}
          </div>
          <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
            <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">
              Checks {errors.length ? `— ${errors.length} to fix` : '— all clear'}
            </div>
            {issues.length
              ? <FenIssueList issues={issues} />
              : <div className="text-xs text-green-700">Every check passes. The loops close, none crosses itself, and the section measures like an extrusion.</div>}
            {tried && errors.length > 0 && (
              <div className="text-xs text-red-700 font-semibold">Fix the errors above and the profile will save.</div>
            )}
            {tried && willDowngrade && !confirmDowngrade && (
              <div className="text-xs text-red-700 font-semibold">Tick the box above to confirm the profile stops being verified CAD.</div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// ── Import CAD ────────────────────────────────────────────────────────────
// Read the file, show what was read, and let a person confirm it. Nothing is
// added to the library by dropping a file — the confirmation IS the import.
function fenDetectFormat(name) {
  const n = String(name || '').toLowerCase();
  if (n.endsWith('.dxf')) return 'dxf';
  if (n.endsWith('.svg')) return 'svg';
  if (n.endsWith('.dwg')) return 'dwg';
  if (n.endsWith('.zip')) return 'zip';
  return 'other';
}
function fenDetectSystem(name, systems) {
  const n = String(name || '').toLowerCase();
  // Matched on the system's own name and family, longest first so "GENEO 4700"
  // wins over "GENEO" when both would match.
  const cands = systems.slice().sort((a, b) => b.name.length - a.name.length);
  for (const s of cands) {
    const key = s.name.toLowerCase().replace(/\s+/g, '');
    if (n.replace(/[\s_-]+/g, '').includes(key)) return s.id;
  }
  for (const s of cands) {
    if (s.family && n.includes(s.family.toLowerCase())) return s.id;
  }
  return null;
}
const FEN_CATEGORY_HINTS = [
  [/glaz|bead|glasleiste/i, 'Glazing Bead'],
  [/mullion|pfosten/i, 'Mullion'],
  [/transom|riegel/i, 'Transom'],
  [/thresh|schwelle/i, 'Threshold'],
  [/reinforc|steel|stahl/i, 'Reinforcement'],
  [/track|rail/i, 'Track'],
  [/coupl/i, 'Coupler'],
  [/adapt/i, 'Adapter'],
  [/sash|vent|fl(u|ü)gel/i, 'Sash'],
  [/frame|rahmen|blend/i, 'Outer Frame'],
];
function fenDetectCategory(name) {
  const hit = FEN_CATEGORY_HINTS.find(([re]) => re.test(String(name || '')));
  return hit ? hit[1] : 'Uncategorised';
}
function fenDetectCode(name) {
  const base = String(name || '').replace(/\.[a-z0-9]+$/i, '');
  // A profile code is the part that looks like a code — letters and digits with
  // a separator — and it is only ever a SUGGESTION in an editable field.
  const m = base.match(/\b([A-Z]{0,4}[-_ ]?\d{3,6}(?:[-_ ]?\d{1,3})?)\b/);
  return m ? m[1].replace(/[_ ]/g, '-') : base.slice(0, 32);
}

// When the real CAD finally lands for a section somebody typed by hand, the
// right move is to REPLACE that record rather than add a second one beside it —
// the assemblies already point at its id, and a duplicate would leave half the
// job referring to the guess. Matched on the profile code, ignoring case and
// separators, and only ever against a profile that is not already verified.
function fenSuggestReplacement(code, profiles) {
  const norm = s => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  const k = norm(code);
  if (!k) return null;
  const hit = (profiles || []).find(p => !fenIsVerifiedCad(p) && norm(p.code) === k);
  return hit ? hit.id : null;
}

function fenReadText(file) {
  if (file.text) return file.text();
  return new Promise((res, rej) => {
    const r = new FileReader();
    r.onload = () => res(String(r.result || ''));
    r.onerror = () => rej(r.error);
    r.readAsText(file);
  });
}

function FenImportPanel({ ctx, lib, editable }) {
  const [rows, setRows] = useState([]);
  const [busy, setBusy] = useState(false);
  const [preview, setPreview] = useState(null);
  const [done, setDone] = useState(null);
  const inputRef = useRef(null);

  function setRow(id, f) { setRows(rs => rs.map(r => r.id === id ? { ...r, ...f } : r)); }

  function rebuild(row, units) {
    if (!row.parse || !row.parse.ok) return { ...row, units, geometry: null };
    const geo = fenBuildGeometry(row.parse, {
      units, fileName: row.fileName, format: row.format, hash: row.hash,
      bytes: row.bytes, text: row.text, dataUrl: row.dataUrl,
      confirmedBy: row.unitsConfirmed ? ctx.currentUserName : null,
    });
    return { ...row, units, geometry: geo };
  }

  async function onFiles(fileList) {
    const files = Array.from(fileList || []);
    if (!files.length) return;
    setBusy(true);
    const next = [];
    for (const f of files) {
      const format = fenDetectFormat(f.name);
      const detectedCode = fenDetectCode(f.name);
      const base = {
        id: uid('fimp'), fileName: f.name, bytes: f.size, format,
        systemId: fenDetectSystem(f.name, lib.systems),
        code: detectedCode, name: f.name.replace(/\.[a-z0-9]+$/i, ''),
        category: fenDetectCategory(f.name),
        action: 'approve', unitsConfirmed: false, units: 'mm',
        // Suggested, never applied on its own — replacing a record in place is
        // a decision, so it is shown as one and can be turned off.
        replaceId: fenSuggestReplacement(detectedCode, lib.profiles),
        parse: null, geometry: null, text: null, dataUrl: null, hash: '', note: '',
      };
      if (format === 'dwg') {
        // A DWG is a closed binary format. There is no converter in a browser,
        // so the file is kept exactly as supplied and labelled for conversion —
        // which is more useful than a refusal that loses the file.
        base.dataUrl = await readFileAsDataURL(f);
        base.hash = fenHash(base.dataUrl);
        base.action = 'hold';
        base.note = 'DWG — needs conversion to DXF. The file is kept as supplied; a browser cannot read the DWG format and nothing here will pretend to.';
        next.push(base);
        continue;
      }
      if (format === 'zip') {
        base.dataUrl = await readFileAsDataURL(f);
        base.hash = fenHash(base.dataUrl);
        base.action = 'hold';
        base.note = 'ZIP — kept as supplied. This app has no unpacker, so expand it and drop the DXF or SVG files in directly.';
        next.push(base);
        continue;
      }
      if (format === 'other') {
        base.action = 'hold';
        base.note = 'Not a DXF or an SVG. A PDF, an image or a datasheet page carries no geometry that can be read.';
        next.push(base);
        continue;
      }
      const text = await fenReadText(f);
      base.text = text;
      base.hash = fenHash(text);
      base.parse = format === 'dxf' ? fenParseDxf(text) : fenParseSvg(text);
      const raw = fenComputeBounds(base.parse.entities);
      const sug = fenSuggestUnits(raw, base.parse.declaredUnitsMm);
      base.units = sug.units;
      base.suggestion = sug;
      base.rawBounds = raw;
      if (!base.parse.ok) { base.action = 'hold'; base.note = (base.parse.warnings || []).join(' ') || 'No geometry was read from this file.'; }
      next.push(rebuild(base, base.units));
    }
    setRows(rs => [...rs, ...next]);
    setBusy(false);
  }

  const ready = rows.filter(r => r.action === 'approve' && r.geometry && r.unitsConfirmed
    && r.geometry.validation.status !== FEN_GEO_INVALID);

  function approveAll() {
    // The importer is the ONLY place FEN_PROV_CAD is ever set. That is the whole
    // guarantee behind the label, so it is written here and nowhere else.
    const fresh = ready.filter(r => !r.replaceId);
    const supersede = ready.filter(r => r.replaceId);
    const added = fresh.map(r => makeFenProfile({
      code: r.code, name: r.name, category: r.category,
      systemId: r.systemId,
      manufacturerId: (fenSystemById(r.systemId) || {}).manufacturerId || null,
      geometry: r.geometry,
      geometryStatus: r.geometry.validation.status,
      provenance: FEN_PROV_CAD,
      createdBy: ctx.currentUserName,
    }));
    fenLibraryUpdate(l => {
      let profiles = [...l.profiles, ...added];
      supersede.forEach(r => {
        profiles = profiles.map(p => {
          if (p.id !== r.replaceId) return p;
          const from = fenProvenanceOf(p);
          return {
            ...p,
            // The id is deliberately untouched: every assembly instance that
            // pointed at the hand-entered section now points at the real one,
            // with nothing to re-assign and nothing orphaned.
            code: r.code || p.code, name: r.name || p.name, category: r.category,
            systemId: r.systemId,
            manufacturerId: (fenSystemById(r.systemId) || {}).manufacturerId || p.manufacturerId,
            geometry: r.geometry, geometryStatus: r.geometry.validation.status,
            provenance: FEN_PROV_CAD,
            provenanceHistory: [...(p.provenanceHistory || []), {
              date: todayISO(), from, to: FEN_PROV_CAD, by: ctx.currentUserName,
              note: `Superseded in place by the manufacturer’s own file ${r.fileName} (fingerprint ${r.hash}). The record kept its id, so every assembly referencing it was upgraded with it.`,
            }],
          };
        });
      });
      // A system stops awaiting CAD only when verified CAD sits under it.
      return fenApplySystemStatuses({ ...l, profiles });
    });
    const bits = [];
    if (added.length) bits.push(`${added.length} profile${added.length === 1 ? '' : 's'} added to the library`);
    if (supersede.length) bits.push(`${supersede.length} hand-entered profile${supersede.length === 1 ? '' : 's'} upgraded in place to ${FEN_PROV_CAD}, keeping their ids`);
    setDone(`${bits.join(' · ')}.`);
    setRows(rs => rs.filter(r => !ready.some(x => x.id === r.id)));
  }

  return (
    <div className="space-y-4">
      <div>
        <h3 className="font-bold">Import manufacturer CAD</h3>
        <p className="text-sm text-[var(--leon-black)]/55 max-w-3xl">
          Drop the manufacturer&rsquo;s cross-section files here. Each one is read as geometry — a real DXF
          group-code walk or a real SVG path walk — and then shown to you with its measured width and
          depth in millimetres, so you can check it against the published section before anything is
          added. Nothing is imported by dropping it; the confirmation is the import.
        </p>
        <p className="text-sm text-[var(--leon-black)]/55 max-w-3xl mt-2">
          This is the <b>only</b> place a profile is marked &ldquo;{FEN_PROV_CAD}&rdquo;. Where a section
          was entered by hand while the file was awaited, set <b>Supersedes</b> on its row and the import
          replaces that record in place — keeping its id, so every assembly already pointing at it is
          upgraded along with it and nothing is left orphaned.
        </p>
      </div>
      <FenSessionNotice />

      <div className="rounded-lg border-2 border-dashed border-[var(--leon-line)] p-6 text-center"
        onDragOver={e => { e.preventDefault(); }}
        onDrop={e => { e.preventDefault(); if (editable) onFiles(e.dataTransfer.files); }}>
        <div className="text-3xl mb-2">📥</div>
        <div className="font-semibold mb-1">Drop DXF or SVG files here</div>
        <div className="text-xs text-[var(--leon-black)]/50 max-w-xl mx-auto mb-3">
          ASCII DXF and SVG are read. A DWG is kept and marked for conversion — the format is binary and
          closed, and no browser can open one without a converter. A ZIP is kept but not unpacked.
        </div>
        {editable && (
          <>
            <Button size="sm" onClick={() => inputRef.current && inputRef.current.click()}>Choose files</Button>
            <input ref={inputRef} type="file" multiple accept=".dxf,.svg,.dwg,.zip" className="hidden"
              onChange={e => { onFiles(e.target.files); e.target.value = ''; }} />
          </>
        )}
        {busy && <div className="text-xs text-[var(--leon-brown)] mt-2">Reading…</div>}
      </div>

      {done && <div className="rounded border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-800">{done}</div>}

      {!!rows.length && (
        <div className="space-y-3">
          <div className="flex items-center gap-2 flex-wrap">
            <h4 className="font-bold text-sm">Import review</h4>
            <span className="text-xs text-[var(--leon-black)]/50">
              {rows.length} file{rows.length === 1 ? '' : 's'} read · {ready.length} ready to approve
            </span>
            <div className="ml-auto flex gap-2">
              <Button size="sm" variant="ghost" onClick={() => setRows([])}>Clear the list</Button>
              <Button size="sm" disabled={!ready.length || !editable} onClick={approveAll}>
                {ready.length ? `Approve ${ready.length} profile${ready.length === 1 ? '' : 's'}` : 'Nothing confirmed yet'}
              </Button>
            </div>
          </div>

          <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
            <table className="w-full text-xs min-w-[1280px]">
              <thead>
                <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                  <th className="px-2 py-2">File</th>
                  <th className="px-2 py-2">System</th>
                  <th className="px-2 py-2">Profile code</th>
                  <th className="px-2 py-2">Category</th>
                  <th className="px-2 py-2">Supersedes</th>
                  <th className="px-2 py-2">Units</th>
                  <th className="px-2 py-2">Width</th>
                  <th className="px-2 py-2">Depth</th>
                  <th className="px-2 py-2">Geometry</th>
                  <th className="px-2 py-2">Action</th>
                </tr>
              </thead>
              <tbody>
                {rows.map(r => {
                  const g = r.geometry;
                  const sys = fenSystemById(r.systemId);
                  const pubW = sys && sys.publishedWidthMm, pubD = sys && sys.publishedDepthMm;
                  const matches = g && pubW && pubD
                    && Math.abs(g.bounds.width - pubW) <= 1.5 && Math.abs(g.bounds.depth - pubD) <= 1.5;
                  return (
                    <tr key={r.id} className={`border-b border-[var(--leon-line)]/60 align-top ${r.action === 'hold' ? 'bg-amber-50/50' : ''}`}>
                      <td className="px-2 py-2">
                        <div className="font-semibold">{r.fileName}</div>
                        <div className="text-[10px] text-[var(--leon-black)]/45">
                          {r.format.toUpperCase()} · {Math.round(r.bytes / 1024)} KB · {r.hash}
                        </div>
                        {r.parse && r.parse.ok && (
                          <button onClick={() => setPreview(r)} className="text-[11px] font-semibold text-[var(--leon-brown)]">Look at it</button>
                        )}
                        {r.note && <div className="text-[11px] text-amber-800 mt-1 max-w-[260px]">{r.note}</div>}
                      </td>
                      <td className="px-2 py-2">
                        <select value={r.systemId || ''} disabled={!editable}
                          onChange={e => setRow(r.id, { systemId: e.target.value || null })}
                          className="w-40 px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                          <option value="">— not matched —</option>
                          {lib.systems.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
                        </select>
                      </td>
                      <td className="px-2 py-2">
                        <input value={r.code} disabled={!editable} onChange={e => setRow(r.id, { code: e.target.value })}
                          className="w-28 px-1 py-0.5 border border-[var(--leon-line)] rounded" />
                      </td>
                      <td className="px-2 py-2">
                        <select value={r.category} disabled={!editable} onChange={e => setRow(r.id, { category: e.target.value })}
                          className="w-32 px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                          {FEN_PROFILE_CATEGORIES.map(c => <option key={c}>{c}</option>)}
                        </select>
                      </td>
                      <td className="px-2 py-2">
                        {/* Only a profile that is NOT already verified can be
                            superseded — a verified record is replaced by
                            re-importing over it deliberately, not by a
                            suggestion in a table. */}
                        <select value={r.replaceId || ''} disabled={!editable}
                          onChange={e => setRow(r.id, { replaceId: e.target.value || null })}
                          className="w-44 px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                          <option value="">— add as a new profile —</option>
                          {lib.profiles.filter(p => !fenIsVerifiedCad(p)).map(p => (
                            <option key={p.id} value={p.id}>{p.code || 'No code'} · {FEN_PROV_SHORT[fenProvenanceOf(p)]}</option>
                          ))}
                        </select>
                        {r.replaceId && (
                          <div className="text-[10px] text-[var(--leon-black)]/55 mt-1 max-w-[210px]">
                            Replaces that record <b>in place, keeping its id</b>, so every assembly already
                            pointing at it is upgraded with it. Its provenance history records the swap.
                          </div>
                        )}
                      </td>
                      <td className="px-2 py-2">
                        <select value={r.units} disabled={!editable || !r.parse}
                          onChange={e => setRows(rs => rs.map(x => x.id === r.id ? rebuild({ ...x, unitsConfirmed: false }, e.target.value) : x))}
                          className="w-36 px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                          {FEN_UNIT_CHOICES.map(u => <option key={u.key} value={u.key}>{u.label}</option>)}
                        </select>
                        {r.parse && (
                          <div className="text-[10px] text-[var(--leon-black)]/50 mt-1 max-w-[200px]">
                            File says: {r.parse.declaredUnits || 'nothing'} ({r.parse.unitsConfidence}).
                            {r.suggestion ? ` Suggested ${r.suggestion.units} — ${r.suggestion.confidence.toLowerCase()} confidence, because ${r.suggestion.why}.` : ''}
                          </div>
                        )}
                        {r.parse && (
                          <label className="flex items-center gap-1 mt-1 text-[11px] font-semibold">
                            <input type="checkbox" checked={!!r.unitsConfirmed} disabled={!editable}
                              onChange={e => setRows(rs => rs.map(x => x.id === r.id ? rebuild({ ...x, unitsConfirmed: e.target.checked }, x.units) : x))} />
                            I have checked these units
                          </label>
                        )}
                      </td>
                      <td className="px-2 py-2 font-semibold tabular-nums">{g ? `${g.bounds.width.toFixed(1)} mm` : '—'}</td>
                      <td className="px-2 py-2 font-semibold tabular-nums">
                        {g ? `${g.bounds.depth.toFixed(1)} mm` : '—'}
                        {g && pubW && pubD && (
                          <div className={`text-[10px] mt-0.5 ${matches ? 'text-green-700' : 'text-red-700'}`}>
                            {matches ? `matches the published ${pubW} × ${pubD} mm`
                              : `published is ${pubW} × ${pubD} mm — this differs`}
                          </div>
                        )}
                        {g && !(pubW && pubD) && (
                          <div className="text-[10px] text-[var(--leon-black)]/45 mt-0.5">
                            no published size on the system to check against
                          </div>
                        )}
                      </td>
                      <td className="px-2 py-2">
                        {g ? <Badge tone={FEN_GEO_TONE[g.validation.status]}>{g.validation.status}</Badge>
                          : <Badge tone="red">Not read</Badge>}
                        {g && !!g.validation.issues.length && (
                          <div className="text-[10px] text-[var(--leon-black)]/55 mt-1 max-w-[260px]">
                            {g.validation.issues[0].msg}
                          </div>
                        )}
                        {r.parse && (
                          <div className="text-[10px] text-[var(--leon-black)]/40 mt-1">
                            {r.parse.entities.length} entities
                            {r.parse.unresolved && r.parse.unresolved.length ? ` · ${r.parse.unresolved.length} unresolved` : ''}
                            {r.parse.blockCount ? ` · ${r.parse.blockCount} blocks, ${r.parse.insertCount} inserts` : ''}
                          </div>
                        )}
                      </td>
                      <td className="px-2 py-2">
                        <select value={r.action} disabled={!editable}
                          onChange={e => setRow(r.id, { action: e.target.value })}
                          className="w-28 px-1 py-0.5 text-xs border border-[var(--leon-line)] rounded bg-white">
                          <option value="approve">Approve</option>
                          <option value="hold">Hold</option>
                          <option value="skip">Skip</option>
                        </select>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
          <p className="text-xs text-[var(--leon-black)]/50 max-w-3xl">
            The original file is kept on every profile that is approved, along with a fingerprint of its
            contents, so a re-import of the same file is recognisable and a changed one is obvious. No
            source is ever replaced or discarded by an import.
          </p>
        </div>
      )}

      <Modal open={!!preview} onClose={() => setPreview(null)} wide
        title={preview ? `${preview.fileName} — as read` : ''}
        footer={<Button onClick={() => setPreview(null)}>Close</Button>}>
        {preview && (
          <div className="space-y-3">
            <div className="bg-[var(--leon-cream)] rounded p-3 grid place-items-center">
              <FenProfileView geometry={preview.geometry} height={340} showDims showOrientation />
            </div>
            {preview.geometry && <FenIssueList issues={preview.geometry.validation.issues} />}
            {!!(preview.parse.warnings || []).length && (
              <div className="text-xs text-amber-800 space-y-1">
                {preview.parse.warnings.map((w, i) => <div key={i}>{w}</div>)}
              </div>
            )}
            {!!(preview.parse.unresolved || []).length && (
              <Collapsible id={`fen-imp-unres-${preview.id}`} title="Read but not resolved" count={preview.parse.unresolved.length}>
                <ul className="text-xs space-y-1">
                  {preview.parse.unresolved.slice(0, 40).map((u, i) => <li key={i}><b>{u.type}</b> — {u.reason}</li>)}
                </ul>
              </Collapsible>
            )}
          </div>
        )}
      </Modal>
    </div>
  );
}

// ── Fenestration Types ────────────────────────────────────────────────────
// A TYPE is the standard (W-A); a MARK is the unit on the wall (W101). Forty
// marks can point at W-A, and changing W-A reaches all forty — except where a
// mark set its own value, which stays put and is shown tinted.
function FenTypesPanel({ ctx, project, system, editable, lib }) {
  const types = project.fenestrationTypes || [];

  function addType() {
    ctx.updateProject(project.id, draft => {
      if (!Array.isArray(draft.fenestrationTypes)) draft.fenestrationTypes = [];
      const code = `W-${String.fromCharCode(65 + draft.fenestrationTypes.length)}`;
      draft.fenestrationTypes.push(makeFenType({ code, name: `Type ${code}`, createdBy: ctx.currentUserName }));
      ctx.logAction(draft, `Added fenestration type ${code}.`);
    });
  }
  function upd(id, f) {
    ctx.updateProject(project.id, draft => {
      const t = (draft.fenestrationTypes || []).find(x => x.id === id);
      if (!t) return;
      Object.assign(t, f);
      ctx.logAction(draft, `Fenestration type ${t.code}: ${Object.keys(f).join(', ')} changed.`);
    });
  }
  function removeType(id) {
    ctx.updateProject(project.id, draft => {
      const t = (draft.fenestrationTypes || []).find(x => x.id === id);
      const used = (draft.fenestrationInstances || []).filter(i => i.typeId === id).length;
      if (used) return;                       // guarded in the UI too; never orphan a mark
      draft.fenestrationTypes = (draft.fenestrationTypes || []).filter(x => x.id !== id);
      if (t) ctx.logAction(draft, `Removed fenestration type ${t.code}.`);
    });
  }

  return (
    <div className="space-y-4">
      <div className="flex items-start justify-between gap-3 flex-wrap">
        <div>
          <h3 className="font-bold">Fenestration Types</h3>
          <p className="text-sm text-[var(--leon-black)]/55 max-w-3xl">
            A type carries the bay layout and the specification. A mark can differ from its type in size,
            glass, finish and allowances — but not in how many bays it has, because a different bay
            layout is a different type, and pretending otherwise is how a schedule stops matching the
            drawings.
          </p>
        </div>
        {editable && <Button size="sm" onClick={addType}>+ Add type</Button>}
      </div>

      {!types.length && <EmptyState text="No types on this project yet. Add one, then lay out its bays in the Assembly Designer." />}

      <div className="grid gap-3 md:grid-cols-2">
        {types.map(t => {
          const computed = fenComputeAssembly(t.assembly);
          const used = (project.fenestrationInstances || []).filter(i => i.typeId === t.id);
          return (
            <div key={t.id} className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
              <div className="flex items-center gap-2 mb-2">
                <input value={t.code} disabled={!editable} onChange={e => upd(t.id, { code: e.target.value })}
                  className="w-24 px-1 py-0.5 font-bold bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                <input value={t.name} disabled={!editable} onChange={e => upd(t.id, { name: e.target.value })}
                  className="flex-1 px-1 py-0.5 text-sm bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                <Badge>{used.length} mark{used.length === 1 ? '' : 's'}</Badge>
              </div>
              <div className="bg-[var(--leon-cream)] rounded mb-2 grid place-items-center h-44 overflow-hidden">
                <FenElevation computed={computed} assembly={t.assembly} system={system} height={160} mark={t.code} />
              </div>
              <div className="text-[11px] text-[var(--leon-black)]/55">
                {fmtDim(computed.W, system, { inchesOnly: true })} × {fmtDim(computed.H, system, { inchesOnly: true })} · {fenOperationSummary(t.assembly)}
              </div>
              <div className="text-[11px] text-[var(--leon-black)]/45">
                {(fenSystemById(t.assembly.systemId) || {}).name || 'No system selected'}
              </div>
              {(() => {
                const ps = fenMarkProfiles(t.assembly);
                const worst = fenWorstProvenance(ps);
                if (!worst) return <div className="text-[11px] text-[var(--leon-black)]/40 mt-1">No profile sections assigned yet</div>;
                return <div className="mt-1"><FenProvenanceTag provenance={worst} /></div>;
              })()}
              {editable && !used.length && (
                <button onClick={() => { if (confirm(`Remove ${t.code}?`)) removeType(t.id); }}
                  className="text-[11px] font-semibold text-red-600 mt-2">Remove type</button>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ── Assembly Designer ─────────────────────────────────────────────────────
function FenDesigner({ ctx, project, system, editable, lib }) {
  const types = project.fenestrationTypes || [];
  const [typeId, setTypeId] = useState(types[0] ? types[0].id : '');
  const [selBay, setSelBay] = useState(null);
  const type = types.find(t => t.id === typeId) || types[0] || null;

  if (!types.length) {
    return <EmptyState text="No types on this project yet. Create one under Fenestration Types, then lay out its bays here." />;
  }
  if (!type) return <EmptyState text="Pick a type." />;

  const asm = type.assembly;
  const computed = fenComputeAssembly(asm);

  function edit(fn, note) {
    ctx.updateProject(project.id, draft => {
      const t = (draft.fenestrationTypes || []).find(x => x.id === type.id);
      if (!t) return;
      fn(t.assembly, t);
      ctx.logAction(draft, `Fenestration type ${t.code}: ${note}`);
    });
  }
  const setAsm = (f, note) => edit(a => Object.assign(a, f), note || `${Object.keys(f).join(', ')} changed.`);

  function addRow() { edit(a => { a.rows.push(makeFenRow()); }, 'row added.'); }
  function addBay(rowId) { edit(a => { const r = a.rows.find(x => x.id === rowId); if (r) r.bays.push(makeFenBay()); }, 'bay added.'); }
  function removeBay(rowId, bayId) {
    edit(a => {
      const r = a.rows.find(x => x.id === rowId);
      if (r && r.bays.length > 1) r.bays = r.bays.filter(b => b.id !== bayId);
    }, 'bay removed.');
  }
  function removeRow(rowId) { edit(a => { if (a.rows.length > 1) a.rows = a.rows.filter(r => r.id !== rowId); }, 'row removed.'); }
  function setBay(rowId, bayId, f) {
    edit(a => {
      const r = a.rows.find(x => x.id === rowId);
      const b = r && r.bays.find(x => x.id === bayId);
      if (b) Object.assign(b, f);
    }, `bay ${Object.keys(f).join(', ')} changed.`);
  }
  function setRow(rowId, f) {
    edit(a => { const r = a.rows.find(x => x.id === rowId); if (r) Object.assign(r, f); }, `row ${Object.keys(f).join(', ')} changed.`);
  }
  // "Divide equally" puts every UNLOCKED bay back to sharing what is left. A
  // locked bay is a decision someone made, so it survives.
  function divideRow(rowId) {
    edit(a => {
      const r = a.rows.find(x => x.id === rowId);
      if (r) r.bays.forEach(b => { if (!b.locked) { b.widthMode = 'auto'; b.width = null; } });
    }, 'bays divided equally.');
  }
  function divideRows() {
    edit(a => { a.rows.forEach(r => { if (!r.locked) { r.heightMode = 'auto'; r.height = null; } }); }, 'rows divided equally.');
  }
  function syncInstances() {
    const req = fenRequiredInstances(asm, computed);
    edit(a => {
      const before = a.instances || [];
      a.instances = req.map(n => {
        const kept = before.find(o => o.key === n.key);
        // Position and cut length are re-derived; the profile a person chose and
        // the joint they set are theirs and are carried over.
        return kept ? { ...n, id: kept.id, profileId: kept.profileId, jointType: kept.jointType, notes: kept.notes || n.notes } : n;
      });
    }, `profile instances synced (${req.length}).`);
  }

  const profiles = lib.profiles;
  const instances = asm.instances || [];
  const staleCount = fenRequiredInstances(asm, computed).length;

  return (
    <div className="grid gap-4 lg:grid-cols-[1fr_400px] items-start">
      <div className="space-y-3">
        <div className="flex items-center gap-2 flex-wrap">
          <Select className="!w-56" value={type.id} onChange={e => { setTypeId(e.target.value); setSelBay(null); }}>
            {types.map(t => <option key={t.id} value={t.id}>{t.code} · {t.name}</option>)}
          </Select>
          <span className="text-xs text-[var(--leon-black)]/45">
            The grid belongs to the type. Sizes for a single mark are set in the Window Schedule.
          </span>
        </div>

        <Collapsible id={`fen-size-${type.id}`} title="Overall size and allowances" defaultOpen>
          <div className="grid gap-3 sm:grid-cols-3">
            <Field label="Overall width"><FenDimField value={asm.width} system={system} disabled={!editable} onChange={v => setAsm({ width: v })} /></Field>
            <Field label="Overall height"><FenDimField value={asm.height} system={system} disabled={!editable} onChange={v => setAsm({ height: v })} /></Field>
            <Field label="Sill height above floor"><FenDimField value={asm.sillHeight} system={system} disabled={!editable} onChange={v => setAsm({ sillHeight: v })} /></Field>
            <Field label="Manufacturer system">
              <Select value={asm.systemId || ''} disabled={!editable}
                onChange={e => setAsm({ systemId: e.target.value || null, manufacturerId: (fenSystemById(e.target.value) || {}).manufacturerId || null })}>
                <option value="">— none —</option>
                {lib.systems.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
              </Select>
            </Field>
            <Field label="Glass"><TextInput value={asm.glass || ''} disabled={!editable} onChange={e => setAsm({ glass: e.target.value })} /></Field>
            <Field label="Finish"><TextInput value={asm.finish || ''} disabled={!editable} onChange={e => setAsm({ finish: e.target.value })} /></Field>
            <Field label="Corner joint">
              <Select value={asm.cornerJoint} disabled={!editable} onChange={e => setAsm({ cornerJoint: e.target.value })}>
                {FEN_JOINT_TYPES.map(j => <option key={j}>{j}</option>)}
              </Select>
            </Field>
            <Field label="Shim each jamb"><FenDimField value={asm.shimJambEach} system={system} disabled={!editable} onChange={v => setAsm({ shimJambEach: v })} /></Field>
            <Field label="Shim head / sill">
              <div className="flex gap-1">
                <FenDimField value={asm.shimHead} system={system} disabled={!editable} w="w-20" onChange={v => setAsm({ shimHead: v })} />
                <FenDimField value={asm.shimSill} system={system} disabled={!editable} w="w-20" onChange={v => setAsm({ shimSill: v })} />
              </div>
            </Field>
          </div>
          <p className="text-[11px] text-[var(--leon-black)]/45 mt-2">
            Shims are a site allowance you declare, not a manufacturer dimension. The rough opening is
            the overall size plus these.
          </p>
        </Collapsible>

        <Collapsible id={`fen-faces-${type.id}`} title="Member face widths">
          <p className="text-sm text-[var(--leon-black)]/60 mb-2">
            These are the visible face widths the elevation draws to. They are <b>declared</b> here until a
            profile with real geometry is assigned to the role below, at which point the geometry supplies
            them. Left blank, members draw as single lines — which is the truth, rather than a rectangle
            of an invented width.
          </p>
          <div className="grid gap-3 sm:grid-cols-3">
            <Field label="Frame face"><FenDimField value={asm.frameFaceWidth} system={system} disabled={!editable} onChange={v => setAsm({ frameFaceWidth: v })} /></Field>
            <Field label="Mullion face"><FenDimField value={asm.mullionFaceWidth} system={system} disabled={!editable} onChange={v => setAsm({ mullionFaceWidth: v })} /></Field>
            <Field label="Transom face"><FenDimField value={asm.transomFaceWidth} system={system} disabled={!editable} onChange={v => setAsm({ transomFaceWidth: v })} /></Field>
          </div>
        </Collapsible>

        <Collapsible id={`fen-grid-${type.id}`} title="Bays" count={computed.bays.length} defaultOpen>
          <div className="space-y-3">
            <div className="flex gap-2 flex-wrap">
              {editable && <Button size="sm" variant="ghost" onClick={addRow}>+ Add row</Button>}
              {editable && <Button size="sm" variant="ghost" onClick={divideRows}>Divide rows equally</Button>}
            </div>
            {computed.rows.map((r, ri) => (
              <div key={r.id} className="rounded border border-[var(--leon-line)] p-2.5 space-y-2">
                <div className="flex items-center gap-2 flex-wrap text-xs">
                  <b>Row {ri + 1}</b>
                  <select value={r.row.heightMode} disabled={!editable}
                    onChange={e => setRow(r.id, { heightMode: e.target.value })}
                    className="px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
                    <option value="auto">Equal share</option>
                    <option value="exact">Exact height</option>
                  </select>
                  {r.row.heightMode === 'exact' &&
                    <FenDimField value={r.row.height} system={system} disabled={!editable} w="w-24" onChange={v => setRow(r.id, { height: v })} />}
                  <span className="text-[var(--leon-black)]/45">= {fmtDim(r.h, system, { inchesOnly: true })}</span>
                  <label className="flex items-center gap-1">
                    <input type="checkbox" checked={!!r.row.locked} disabled={!editable}
                      onChange={e => setRow(r.id, { locked: e.target.checked })} /> lock
                  </label>
                  <div className="ml-auto flex gap-2">
                    {editable && <button onClick={() => addBay(r.id)} className="font-semibold text-[var(--leon-brown)]">+ Bay</button>}
                    {editable && <button onClick={() => divideRow(r.id)} className="font-semibold text-[var(--leon-brown)]">Divide equally</button>}
                    {editable && computed.rows.length > 1 && <button onClick={() => removeRow(r.id)} className="text-red-600">Remove row</button>}
                  </div>
                </div>
                <div className="overflow-x-auto">
                  <table className="w-full text-xs min-w-[620px]">
                    <thead>
                      <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">
                        <th className="px-1 py-1">Bay</th>
                        <th className="px-1 py-1">Operation</th>
                        <th className="px-1 py-1">Width</th>
                        <th className="px-1 py-1">Computed</th>
                        <th className="px-1 py-1">Mark</th>
                        <th className="px-1 py-1"></th>
                      </tr>
                    </thead>
                    <tbody>
                      {r.bays.map((cell, bi) => (
                        <tr key={cell.id} className={selBay === cell.id ? 'bg-[var(--leon-cream)]' : ''}
                          onClick={() => setSelBay(cell.id)}>
                          <td className="px-1 py-1 font-semibold">{ri + 1}.{bi + 1}</td>
                          <td className="px-1 py-1">
                            <select value={cell.bay.operation} disabled={!editable}
                              onChange={e => setBay(r.id, cell.id, { operation: e.target.value })}
                              className="w-32 px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
                              {FEN_OPERATIONS.map(o => <option key={o}>{o}</option>)}
                            </select>
                          </td>
                          <td className="px-1 py-1">
                            <div className="flex items-center gap-1">
                              <select value={cell.bay.widthMode} disabled={!editable}
                                onChange={e => setBay(r.id, cell.id, { widthMode: e.target.value })}
                                className="px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
                                <option value="auto">Equal</option>
                                <option value="exact">Exact</option>
                              </select>
                              {cell.bay.widthMode === 'exact' &&
                                <FenDimField value={cell.bay.width} system={system} disabled={!editable} w="w-24"
                                  onChange={v => setBay(r.id, cell.id, { width: v })} />}
                              <label className="flex items-center gap-1">
                                <input type="checkbox" checked={!!cell.bay.locked} disabled={!editable}
                                  onChange={e => setBay(r.id, cell.id, { locked: e.target.checked })} /> lock
                              </label>
                            </div>
                          </td>
                          <td className="px-1 py-1 tabular-nums font-semibold">{fmtDim(cell.w, system, { inchesOnly: true })}</td>
                          <td className="px-1 py-1">
                            <input value={cell.bay.mark || ''} disabled={!editable}
                              onChange={e => setBay(r.id, cell.id, { mark: e.target.value })}
                              className="w-20 px-1 py-0.5 border border-transparent hover:border-[var(--leon-line)] rounded bg-transparent" />
                          </td>
                          <td className="px-1 py-1">
                            {editable && r.bays.length > 1 &&
                              <button onClick={() => removeBay(r.id, cell.id)} className="text-red-600">✕</button>}
                          </td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                </div>
              </div>
            ))}
            <FenIssueList issues={computed.issues} />
          </div>
        </Collapsible>

        <Collapsible id={`fen-inst-${type.id}`} title="Profile instances" count={instances.length}>
          <div className="space-y-2">
            <p className="text-sm text-[var(--leon-black)]/60">
              Every member below is a <b>placement</b> of a profile — a position, a rotation and a cut
              length — not a copy of it. One extrusion assigned to Head serves the sill and both jambs by
              transform. Cut lengths read &ldquo;—&rdquo; until a face width is known, because a cut length that
              ignores the section is a number that gets a bar cut wrong.
            </p>
            {editable && (
              <div className="flex items-center gap-2">
                <Button size="sm" variant="ghost" onClick={syncInstances}>Sync from the grid ({staleCount})</Button>
                {instances.length !== staleCount &&
                  <span className="text-xs text-amber-800">The grid has changed since these were generated.</span>}
              </div>
            )}
            {!profiles.length && (
              <div className="text-xs text-[var(--leon-black)]/55">
                No profiles are in the library yet, so no member can be assigned one. Import the
                manufacturer&rsquo;s CAD, or enter a section by hand off their dimensioned drawing, and
                every row here gains a real section.
              </div>
            )}
            <FenFabricationNotice what="This assembly"
              profiles={instances.map(i => fenProfileById(i.profileId))} />
            {!!instances.length && (
              <div className="overflow-x-auto">
                <table className="w-full text-xs min-w-[760px]">
                  <thead>
                    <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                      <th className="px-2 py-1.5">Member</th>
                      <th className="px-2 py-1.5">Role</th>
                      <th className="px-2 py-1.5">Profile</th>
                      <th className="px-2 py-1.5">Section from</th>
                      <th className="px-2 py-1.5">x, y (mm)</th>
                      <th className="px-2 py-1.5">Rotation</th>
                      <th className="px-2 py-1.5">Mirror</th>
                      <th className="px-2 py-1.5">Qty</th>
                      <th className="px-2 py-1.5">Cut length</th>
                      <th className="px-2 py-1.5">Joint</th>
                    </tr>
                  </thead>
                  <tbody>
                    {instances.map(inst => (
                      <tr key={inst.id} className="border-b border-[var(--leon-line)]/60">
                        <td className="px-2 py-1 font-semibold">{inst.key}</td>
                        <td className="px-2 py-1">{inst.role}</td>
                        <td className="px-2 py-1">
                          <select value={inst.profileId || ''} disabled={!editable || !profiles.length}
                            onChange={e => edit(a => {
                              const x = (a.instances || []).find(i => i.id === inst.id);
                              if (x) x.profileId = e.target.value || null;
                            }, `${inst.key} profile assigned.`)}
                            className="w-44 px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
                            <option value="">{profiles.length ? '— none —' : 'no profiles in the library'}</option>
                            {/* The provenance is in the option text too, so the
                                choice is informed before it is made rather than
                                explained after it. */}
                            {profiles.map(p => (
                              <option key={p.id} value={p.id}>
                                {p.code} · {p.name} — {FEN_PROV_SHORT[fenProvenanceOf(p)]}
                              </option>
                            ))}
                          </select>
                        </td>
                        <td className="px-2 py-1">
                          {inst.profileId ? <FenProvenanceTag profile={fenProfileById(inst.profileId)} />
                            : <span className="text-[var(--leon-black)]/35">—</span>}
                        </td>
                        <td className="px-2 py-1 tabular-nums">{Math.round(inst.x)}, {Math.round(inst.y)}</td>
                        <td className="px-2 py-1">{inst.rotation}°</td>
                        <td className="px-2 py-1">{inst.mirrorX ? 'X' : ''}{inst.mirrorY ? 'Y' : ''}{!inst.mirrorX && !inst.mirrorY ? '—' : ''}</td>
                        <td className="px-2 py-1">{inst.qty}</td>
                        <td className="px-2 py-1 tabular-nums">{inst.cutLength === null ? '—' : `${Math.round(inst.cutLength)} mm`}</td>
                        <td className="px-2 py-1">
                          <select value={inst.jointType} disabled={!editable}
                            onChange={e => edit(a => {
                              const x = (a.instances || []).find(i => i.id === inst.id);
                              if (x) x.jointType = e.target.value;
                            }, `${inst.key} joint set.`)}
                            className="w-36 px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
                            {FEN_JOINT_TYPES.map(j => <option key={j}>{j}</option>)}
                          </select>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            )}
          </div>
        </Collapsible>
      </div>

      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-3 lg:sticky lg:top-4">
        <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45">{type.code} — elevation</div>
        <div className="bg-[var(--leon-cream)] rounded p-2 grid place-items-center">
          <FenElevation computed={computed} assembly={asm} system={system} height={320} showDims
            mark={type.code} interactive selectedBayId={selBay} onPickBay={setSelBay} />
        </div>
        <FenElevationLegend />
        <div className="text-xs space-y-1 border-t border-[var(--leon-line)] pt-2">
          {[['Overall', `${fmtDim(computed.W, system)} × ${fmtDim(computed.H, system)}`],
            ['Rough opening', `${fmtDim(computed.ro.w, system)} × ${fmtDim(computed.ro.h, system)}`],
            ['Bays', `${computed.bays.length} in ${computed.rows.length} row${computed.rows.length === 1 ? '' : 's'}`],
            ['Operation', fenOperationSummary(asm)]].map(([k, v]) => (
            <div key={k} className="flex justify-between gap-3">
              <span className="text-[var(--leon-black)]/50">{k}</span>
              <span className="font-semibold text-right">{v}</span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

// ── Window Schedule ───────────────────────────────────────────────────────
// The schedule is not a report OF the marks — it IS the marks, in a table.
// Editing a cell here writes the same record the designer reads, which is the
// only arrangement in which the two can never disagree.
const FEN_SCHEDULE_COLUMNS = [
  { key: 'mark', label: 'Mark' }, { key: 'type', label: 'Type' }, { key: 'qty', label: 'Qty' },
  { key: 'width', label: 'Width' }, { key: 'height', label: 'Height' },
  { key: 'roW', label: 'RO W' }, { key: 'roH', label: 'RO H' },
  { key: 'operation', label: 'Operation' }, { key: 'manufacturer', label: 'Manufacturer' },
  { key: 'system', label: 'System' },
  // Where this mark's sections came from. A schedule is the document a mark is
  // ordered and built from, so it carries the same warning the cut list does.
  { key: 'sections', label: 'Sections' },
  { key: 'glass', label: 'Glass' }, { key: 'finish', label: 'Finish' },
  { key: 'location', label: 'Location' }, { key: 'status', label: 'Status' },
];

// The profiles a mark's own type actually assigns — what its schedule row has
// to report the provenance of.
function fenMarkProfiles(assembly) {
  return ((assembly && assembly.instances) || []).map(i => fenProfileById(i.profileId)).filter(Boolean);
}

function FenSchedule({ ctx, project, system, editable, onDesign }) {
  const [q, setQ] = useState('');
  const types = project.fenestrationTypes || [];
  const insts = project.fenestrationInstances || [];

  const rows = insts.map(i => {
    const r = fenResolveInstance(project, i);
    const sys = fenSystemById(r.assembly.systemId);
    const mfr = sys ? fenManufacturerById(sys.manufacturerId) : null;
    const markProfiles = fenMarkProfiles(r.assembly);
    return { inst: i, ...r, sys, mfr, markProfiles, markProvenance: fenWorstProvenance(markProfiles) };
  }).filter(r => !q.trim()
    || `${r.inst.mark} ${r.inst.location} ${(r.type || {}).code || ''}`.toLowerCase().includes(q.trim().toLowerCase()));

  function addMark() {
    ctx.updateProject(project.id, draft => {
      if (!Array.isArray(draft.fenestrationInstances)) draft.fenestrationInstances = [];
      const n = draft.fenestrationInstances.length + 1;
      const mark = `W${100 + n}`;
      draft.fenestrationInstances.push(makeFenInstance({
        mark, typeId: (draft.fenestrationTypes || [])[0] ? draft.fenestrationTypes[0].id : null,
        createdBy: ctx.currentUserName,
      }));
      ctx.logAction(draft, `Added fenestration mark ${mark}.`);
    });
  }
  function setInst(id, f) {
    ctx.updateProject(project.id, draft => {
      const i = (draft.fenestrationInstances || []).find(x => x.id === id);
      if (!i) return;
      Object.assign(i, f);
      ctx.logAction(draft, `Fenestration ${i.mark}: ${Object.keys(f).join(', ')} changed.`);
    });
  }
  function setOverride(id, key, value) {
    ctx.updateProject(project.id, draft => {
      const i = (draft.fenestrationInstances || []).find(x => x.id === id);
      if (!i) return;
      if (!i.overrides) i.overrides = {};
      if (value === null || value === '' || value === undefined) {
        delete i.overrides[key];
        ctx.logAction(draft, `Fenestration ${i.mark}: ${key} back to the type.`);
      } else {
        i.overrides[key] = value;
        ctx.logAction(draft, `Fenestration ${i.mark}: ${key} set on this mark only.`);
      }
    });
  }
  function removeMark(id) {
    ctx.updateProject(project.id, draft => {
      const i = (draft.fenestrationInstances || []).find(x => x.id === id);
      draft.fenestrationInstances = (draft.fenestrationInstances || []).filter(x => x.id !== id);
      if (i) ctx.logAction(draft, `Removed fenestration mark ${i.mark}.`);
    });
  }
  function exportCsv() {
    downloadCsv(`${project.name} — window schedule`, FEN_SCHEDULE_COLUMNS, rows.map(r => ({
      mark: r.inst.mark, type: r.type ? r.type.code : '', qty: r.inst.qty,
      width: fmtDim(r.computed.W, system, { inchesOnly: true }),
      height: fmtDim(r.computed.H, system, { inchesOnly: true }),
      roW: fmtDim(r.computed.ro.w, system, { inchesOnly: true }),
      roH: fmtDim(r.computed.ro.h, system, { inchesOnly: true }),
      operation: fenOperationSummary(r.assembly),
      manufacturer: r.mfr ? r.mfr.name : '', system: r.sys ? r.sys.name : '',
      sections: fenWorstProvenance(fenMarkProfiles(r.assembly)) || 'No sections assigned',
      glass: r.assembly.glass || '', finish: r.assembly.finish || '',
      location: r.inst.location || '', status: r.inst.status,
    })));
  }

  return (
    <div className="space-y-3">
      <HubTools title={`${project.name} — Window Schedule`} heading="Window Schedule"
        lines={[project.name, `${insts.length} marks`]} />
      <div className="flex items-center gap-2 flex-wrap">
        <TextInput className="!w-64" value={q} onChange={e => setQ(e.target.value)} placeholder="Search mark, location, type…" />
        <span className="text-xs text-[var(--leon-black)]/50">
          {rows.length} mark{rows.length === 1 ? '' : 's'} · {rows.reduce((a, r) => a + (Number(r.inst.qty) || 1), 0)} units
        </span>
        <div className="ml-auto flex gap-2">
          <Button size="sm" variant="ghost" onClick={exportCsv} disabled={!rows.length}>Export CSV</Button>
          {editable && <Button size="sm" onClick={addMark} disabled={!types.length}>+ Add mark</Button>}
        </div>
      </div>
      {!types.length && (
        <div className="text-sm text-[var(--leon-black)]/55">
          There are no fenestration types on this project yet, and a mark has to point at one.
          <button onClick={onDesign} className="ml-1 font-semibold text-[var(--leon-brown)]">Open the designer</button>.
        </div>
      )}

      <FenFabricationNotice what="This schedule"
        profiles={rows.reduce((a, r) => a.concat(r.markProfiles || []), [])} />

      <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
        <table className="w-full text-xs min-w-[1380px]">
          <thead>
            <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
              {FEN_SCHEDULE_COLUMNS.map(c => <th key={c.key} className="px-2 py-2">{c.label}</th>)}
              <th className="px-2 py-2 w-8"></th>
            </tr>
          </thead>
          <tbody>
            {rows.map(r => {
              const i = r.inst;
              const own = k => r.ownFields.includes(k);
              return (
                <tr key={i.id} className="border-b border-[var(--leon-line)]/60">
                  <td className="px-2 py-1">
                    <input value={i.mark} disabled={!editable} onChange={e => setInst(i.id, { mark: e.target.value })}
                      className="w-20 px-1 py-0.5 font-bold bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                  </td>
                  <td className="px-2 py-1">
                    <select value={i.typeId || ''} disabled={!editable} onChange={e => setInst(i.id, { typeId: e.target.value || null })}
                      className="w-28 px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
                      <option value="">— none —</option>
                      {types.map(t => <option key={t.id} value={t.id}>{t.code}</option>)}
                    </select>
                  </td>
                  <td className="px-2 py-1">
                    <input type="number" value={i.qty} disabled={!editable}
                      onChange={e => setInst(i.id, { qty: Number(e.target.value) || 1 })}
                      className="w-12 px-1 py-0.5 text-right bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                  </td>
                  {/* A size typed here is an override on this mark only, and the
                      cell is tinted so it is never invisible — the same rule the
                      door schedule uses. */}
                  {[['width', r.computed.W], ['height', r.computed.H]].map(([k, v]) => (
                    <td key={k} className={`px-2 py-1 ${own(k) ? 'bg-amber-50' : ''}`}
                      title={own(k) ? 'Set on this mark, not inherited from its type' : 'Inherited from the type'}>
                      <FenDimField value={v} system={system} disabled={!editable} w="w-24"
                        onChange={mm => setOverride(i.id, k, mm)} />
                    </td>
                  ))}
                  <td className="px-2 py-1 font-semibold text-[var(--leon-brown)] whitespace-nowrap">{fmtDim(r.computed.ro.w, system, { inchesOnly: true })}</td>
                  <td className="px-2 py-1 font-semibold text-[var(--leon-brown)] whitespace-nowrap">{fmtDim(r.computed.ro.h, system, { inchesOnly: true })}</td>
                  <td className="px-2 py-1 text-[var(--leon-black)]/70 max-w-[200px]">{fenOperationSummary(r.assembly)}</td>
                  <td className="px-2 py-1 text-[var(--leon-black)]/60">{r.mfr ? r.mfr.name : '—'}</td>
                  <td className="px-2 py-1 text-[var(--leon-black)]/60">{r.sys ? r.sys.name : '—'}</td>
                  <td className="px-2 py-1">
                    {r.markProvenance
                      ? <FenProvenanceTag provenance={r.markProvenance} />
                      : <span className="text-[var(--leon-black)]/35" title="No profile has been assigned to any member of this mark’s type.">none assigned</span>}
                  </td>
                  <td className={`px-2 py-1 ${own('glass') ? 'bg-amber-50' : ''}`}>
                    <input value={r.assembly.glass || ''} disabled={!editable}
                      onChange={e => setOverride(i.id, 'glass', e.target.value)}
                      className="w-28 px-1 py-0.5 bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                  </td>
                  <td className={`px-2 py-1 ${own('finish') ? 'bg-amber-50' : ''}`}>
                    <input value={r.assembly.finish || ''} disabled={!editable}
                      onChange={e => setOverride(i.id, 'finish', e.target.value)}
                      className="w-28 px-1 py-0.5 bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                  </td>
                  <td className="px-2 py-1">
                    <input value={i.location || ''} disabled={!editable} onChange={e => setInst(i.id, { location: e.target.value })}
                      className="w-32 px-1 py-0.5 bg-transparent border border-transparent hover:border-[var(--leon-line)] rounded" />
                  </td>
                  <td className="px-2 py-1">
                    <select value={i.status} disabled={!editable} onChange={e => setInst(i.id, { status: e.target.value })}
                      className="w-36 px-1 py-0.5 border border-[var(--leon-line)] rounded bg-white">
                      {FEN_STATUSES.map(s => <option key={s}>{s}</option>)}
                    </select>
                  </td>
                  <td className="px-2 py-1">
                    {editable && <button onClick={() => { if (confirm(`Remove ${i.mark}?`)) removeMark(i.id); }}
                      className="text-red-600" title="Remove">✕</button>}
                  </td>
                </tr>
              );
            })}
            {!rows.length && <tr><td colSpan={FEN_SCHEDULE_COLUMNS.length + 1} className="px-3 py-6 text-center text-[var(--leon-black)]/40">No fenestration marks on this project yet.</td></tr>}
          </tbody>
        </table>
      </div>

      {!!rows.length && (
        <Collapsible id={`fen-elevs-${project.id}`} title="Elevations" count={rows.length}>
          <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
            {rows.map(r => (
              <div key={r.inst.id} className="rounded-lg border border-[var(--leon-line)] bg-white p-2">
                <div className="bg-[var(--leon-cream)] rounded grid place-items-center h-44 overflow-hidden">
                  <FenElevation computed={r.computed} assembly={r.assembly} system={system} height={160} mark={r.inst.mark} />
                </div>
                <div className="text-xs font-bold mt-1">{r.inst.mark}{r.type ? ` · ${r.type.code}` : ''}</div>
                <div className="text-[11px] text-[var(--leon-black)]/50">
                  {fmtDim(r.computed.W, system, { inchesOnly: true })} × {fmtDim(r.computed.H, system, { inchesOnly: true })} · {r.inst.location || 'no location'}
                </div>
              </div>
            ))}
          </div>
          <div className="mt-2"><FenElevationLegend /></div>
        </Collapsible>
      )}
    </div>
  );
}

// ── Sections & Details ────────────────────────────────────────────────────
// A head, jamb or sill detail IS the profile's cross-section, placed and cut.
// With no cross-section on file there is nothing to draw — and a generic
// rectangle labelled "jamb" would be worse than an empty screen, because it
// would be believed.
function FenDetailsPanel({ ctx, project, lib, system }) {
  const [role, setRole] = useState('Head');
  const withGeo = fenProfilesWithGeometry();
  const types = (project && project.fenestrationTypes) || [];
  const [typeId, setTypeId] = useState(types[0] ? types[0].id : '');
  const type = types.find(t => t.id === typeId) || types[0] || null;

  if (!withGeo.length) {
    return (
      <div className="space-y-3">
        <h3 className="font-bold">Sections &amp; Details</h3>
        <FenGap title="Sections and details are not available yet." what={FEN_SUPPLY_LINE}>
          A head, jamb or sill detail is a real cross-section, placed at that position and cut. There is no
          profile geometry in the library at all, so there is nothing to cut — and a drawn-from-memory
          rectangle would be read as a real detail by whoever received it. Import the manufacturer&rsquo;s
          CAD, or enter the section by hand off their dimensioned drawing, and every detail on this screen
          is generated from the same coordinates the profile viewer shows — tagged with where those
          coordinates came from.
        </FenGap>
      </div>
    );
  }

  const inst = type ? (type.assembly.instances || []).find(x => x.role === role) : null;
  const prof = inst ? fenProfileById(inst.profileId) : null;
  return (
    <div className="space-y-3">
      <div className="flex items-end gap-2 flex-wrap">
        <div>
          <h3 className="font-bold">Sections &amp; Details</h3>
          <p className="text-sm text-[var(--leon-black)]/55 max-w-3xl">
            Each detail is the assigned profile, drawn from its stored coordinates at the position that
            role occupies. It is the same geometry the profile viewer shows — not a redrawing of it.
          </p>
        </div>
        <div className="ml-auto flex gap-2">
          {!!types.length && (
            <Field label="Type">
              <Select className="!w-40" value={type ? type.id : ''} onChange={e => setTypeId(e.target.value)}>
                {types.map(t => <option key={t.id} value={t.id}>{t.code}</option>)}
              </Select>
            </Field>
          )}
          <Field label="Detail">
            <Select className="!w-40" value={role} onChange={e => setRole(e.target.value)}>
              {FEN_ROLES.map(r => <option key={r}>{r}</option>)}
            </Select>
          </Field>
        </div>
      </div>
      {!type && <EmptyState text="Pick a project with a fenestration type to cut a detail from." />}
      {type && !inst && (
        <div className="text-sm text-[var(--leon-black)]/55">
          {type.code} has no {role} member yet. Sync the profile instances in the designer first.
        </div>
      )}
      {inst && !prof && (
        <div className="text-sm text-[var(--leon-black)]/55">
          {type.code}&rsquo;s {role} has no profile assigned. Assign one in the designer and the detail draws itself.
        </div>
      )}
      {prof && (
        <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
          <div className="flex items-center gap-2 flex-wrap">
            <div className="text-xs font-bold">{type.code} — {role} · {prof.code} {prof.name}</div>
            <FenProvenanceTag profile={prof} />
            {prof.sourceDocName && <span className="text-[11px] text-[var(--leon-black)]/50">from {prof.sourceDocName}</span>}
          </div>
          <FenFabricationNotice what="This detail" profiles={[prof]} />
          <div className="bg-[var(--leon-cream)] rounded p-3 grid place-items-center">
            <FenProfileView geometry={prof.geometry} height={380} showDims showOrientation />
          </div>
          {/* The figures a detailer reads off a section alongside the drawing.
              Blank where the manufacturer's value has not been recorded — an
              unstated figure is stated as unstated. */}
          <div className="grid gap-x-6 gap-y-1 sm:grid-cols-2 text-[11px]">
            {[['Wall thickness', prof.wallThicknessMm ? `${prof.wallThicknessMm} mm` : 'not stated'],
              ['Glazing pocket', prof.glazingPocketMm ? `${prof.glazingPocketMm} mm` : 'not stated'],
              ['Thermal break', prof.thermalBreak || 'Not stated'],
              ['Weight per metre', prof.weightPerMetreKg ? `${prof.weightPerMetreKg} kg/m` : 'not stated'],
              ['Material', prof.material || 'not stated'],
              ['Finish options', prof.finishOptions || 'not stated']].map(([k, v]) => (
              <div key={k} className="flex justify-between gap-3 border-b border-[var(--leon-line)]/50 py-0.5">
                <span className="text-[var(--leon-black)]/50">{k}</span>
                <span className="font-semibold text-right">{v}</span>
              </div>
            ))}
          </div>
          <div className="text-[11px] text-[var(--leon-black)]/50">
            Placed at {Math.round(inst.x)}, {Math.round(inst.y)} mm, rotated {inst.rotation}°
            {inst.mirrorX ? ', mirrored in X' : ''}{inst.mirrorY ? ', mirrored in Y' : ''}.
            The glass line and the surrounding construction are not drawn — they are not in the
            profile record and are not invented here.
          </div>
        </div>
      )}
    </div>
  );
}

// ── BOM & Cut List ────────────────────────────────────────────────────────
// A cut list is a promise about a saw. It is only made when the section behind
// every member is real, because a length that ignores the profile's rebate and
// its corner allowance cuts a bar short — and a short bar is scrap.
function fenBuildBom(project, system) {
  const types = (project && project.fenestrationTypes) || [];
  const insts = (project && project.fenestrationInstances) || [];
  if (!insts.length) return { available: false, reason: 'There are no fenestration marks on this project yet.' };

  const missing = [];
  const rows = [];
  insts.forEach(mark => {
    const type = types.find(t => t.id === mark.typeId);
    if (!type) { missing.push(`${mark.mark} has no type`); return; }
    const members = type.assembly.instances || [];
    if (!members.length) { missing.push(`${type.code} has no profile instances — sync them in the designer`); return; }
    members.forEach(m => {
      const prof = fenProfileById(m.profileId);
      if (!prof || !prof.geometry) { missing.push(`${type.code} ${m.key} has no profile with geometry`); return; }
      if (m.cutLength === null || m.cutLength === undefined) { missing.push(`${type.code} ${m.key} has no derivable cut length`); return; }
      const qty = (Number(mark.qty) || 1) * (Number(m.qty) || 1);
      const key = `${prof.id}|${Math.round(m.cutLength)}|${m.jointType}`;
      const found = rows.find(r => r.key === key);
      if (found) { found.qty += qty; found.marks.push(mark.mark); }
      else rows.push({ key, profile: prof, role: m.role, cutLength: m.cutLength, joint: m.jointType, qty, marks: [mark.mark] });
    });
  });

  if (missing.length) {
    return { available: false, reason: 'Every member has to resolve to a real profile section before a cut list means anything.', missing };
  }
  // The distinct profiles this list rests on, so the panel can name the ones
  // that did not come from the manufacturer's file rather than count them.
  const seen = {};
  rows.forEach(r => { seen[r.profile.id] = r.profile; });
  const profiles = Object.keys(seen).map(k => seen[k]);
  return { available: true, rows, profiles, worstProvenance: fenWorstProvenance(profiles) };
}

function FenBomPanel({ ctx, project, lib, system }) {
  if (!project) return <EmptyState text="Pick a project to build a bill of materials for." />;
  const bom = fenBuildBom(project, system);

  if (!bom.available) {
    return (
      <div className="space-y-3">
        <h3 className="font-bold">BOM &amp; Cut List</h3>
        <FenGap title="The bill of materials and the cut list are not available yet." what={FEN_SUPPLY_LINE}>
          Both are derived from real profile geometry — a cut length has to account for the section&rsquo;s
          rebate and its corner allowance, and neither exists until a cross-section is on file.
          {' '}{bom.reason} Import the manufacturer&rsquo;s CAD, or enter the section by hand off their
          dimensioned drawing; a list built on a hand-entered section is produced and says so on every
          line, so nobody cuts to it without knowing.
        </FenGap>
        {!!(bom.missing || []).length && (
          <Collapsible id={`fen-bom-missing-${project.id}`} title="What is missing" count={bom.missing.length}>
            <ul className="text-xs space-y-1">
              {bom.missing.slice(0, 60).map((m, i) => <li key={i}>· {m}</li>)}
            </ul>
          </Collapsible>
        )}
      </div>
    );
  }

  const totalLength = bom.rows.reduce((a, r) => a + r.cutLength * r.qty, 0);
  // Weight is only totalled where EVERY line has a published kg/m. A partial
  // total would read as the weight of the job and be short by whatever was
  // missing, which is worse than no figure at all.
  const weighable = bom.rows.every(r => Number(r.profile.weightPerMetreKg) > 0);
  const totalWeight = weighable
    ? bom.rows.reduce((a, r) => a + (r.cutLength * r.qty / 1000) * Number(r.profile.weightPerMetreKg), 0) : null;
  const unverified = fenUnverifiedAmong(bom.profiles);

  return (
    <div className="space-y-3">
      <HubTools title={`${project.name} — Fenestration BOM`} heading="Fenestration BOM and Cut List"
        lines={[project.name]} />
      <div className="flex items-center gap-2 flex-wrap">
        <h3 className="font-bold">BOM &amp; Cut List</h3>
        <span className="text-xs text-[var(--leon-black)]/50">
          {bom.rows.length} line{bom.rows.length === 1 ? '' : 's'} · {Math.round(totalLength / 1000)} m of profile
          {totalWeight === null ? '' : ` · ${totalWeight.toFixed(1)} kg`}
        </span>
        {bom.worstProvenance && bom.worstProvenance !== FEN_PROV_CAD && <FenProvenanceTag provenance={bom.worstProvenance} />}
        <div className="ml-auto">
          <Button size="sm" variant="ghost" onClick={() => downloadCsv(`${project.name} — fenestration cut list`,
            [{ key: 'code', label: 'Profile' }, { key: 'name', label: 'Name' }, { key: 'role', label: 'Role' },
             { key: 'cut', label: 'Cut length (mm)' }, { key: 'joint', label: 'Joint' },
             { key: 'qty', label: 'Qty' }, { key: 'marks', label: 'Marks' },
             // The provenance goes in the exported file too. A cut list leaves
             // this app and gets printed next to a saw, and it has to carry the
             // same warning the screen does.
             { key: 'provenance', label: 'Geometry provenance' }, { key: 'source', label: 'Section source' }],
            bom.rows.map(r => ({ code: r.profile.code, name: r.profile.name, role: r.role,
              cut: Math.round(r.cutLength), joint: r.joint, qty: r.qty, marks: r.marks.join(' '),
              provenance: fenProvenanceOf(r.profile), source: r.profile.sourceDocName || (r.profile.geometry ? r.profile.geometry.sourceFile : '') })))}>
            Export CSV
          </Button>
        </div>
      </div>

      <FenFabricationNotice what="This cut list" profiles={bom.profiles} />

      <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
        <table className="w-full text-xs min-w-[980px]">
          <thead>
            <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
              <th className="px-2 py-2">Profile</th><th className="px-2 py-2">Section from</th>
              <th className="px-2 py-2">Role</th>
              <th className="px-2 py-2">Cut length</th><th className="px-2 py-2">Joint</th>
              <th className="px-2 py-2">Qty</th><th className="px-2 py-2">Total length</th>
              <th className="px-2 py-2">Weight</th>
              <th className="px-2 py-2">Marks</th>
            </tr>
          </thead>
          <tbody>
            {bom.rows.map(r => {
              const kg = Number(r.profile.weightPerMetreKg) > 0
                ? (r.cutLength * r.qty / 1000) * Number(r.profile.weightPerMetreKg) : null;
              return (
                <tr key={r.key} className={`border-b border-[var(--leon-line)]/60 ${fenIsVerifiedCad(r.profile) ? '' : 'bg-amber-50/50'}`}>
                  <td className="px-2 py-1.5 font-semibold">{r.profile.code} · {r.profile.name}</td>
                  <td className="px-2 py-1.5"><FenProvenanceTag profile={r.profile} /></td>
                  <td className="px-2 py-1.5">{r.role}</td>
                  <td className="px-2 py-1.5 tabular-nums">{Math.round(r.cutLength)} mm</td>
                  <td className="px-2 py-1.5">{r.joint}</td>
                  <td className="px-2 py-1.5 tabular-nums">{r.qty}</td>
                  <td className="px-2 py-1.5 tabular-nums">{(r.cutLength * r.qty / 1000).toFixed(2)} m</td>
                  <td className="px-2 py-1.5 tabular-nums">{kg === null ? <span className="text-[var(--leon-black)]/35">no kg/m on file</span> : `${kg.toFixed(1)} kg`}</td>
                  <td className="px-2 py-1.5 text-[var(--leon-black)]/55">{Array.from(new Set(r.marks)).join(', ')}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      <p className="text-xs text-[var(--leon-black)]/50 max-w-3xl">
        Lengths are the member lengths this assembly needs. Bar-length optimisation — fitting these onto
        6 m stock with the least offcut — is a separate calculation and is not done here.
        {unverified.length
          ? ` ${unverified.length} of the section${unverified.length === 1 ? '' : 's'} behind these lengths ${unverified.length === 1 ? 'was' : 'were'} not parsed from the manufacturer’s file; the exported CSV carries that on every row.`
          : ' Every section behind these lengths came out of the manufacturer’s own CAD.'}
        {totalWeight === null ? ' Weight is not totalled because at least one profile has no published kg/m — a partial total would read as the whole job’s weight.' : ''}
      </p>
    </div>
  );
}
