Source: components/PropertiesPanel/PropertiesPanel.jsx

/**
 * Right-hand properties panel for the AutoRA Workflow Editor. Displays the
 * editable parameters, filter settings, inputs, and outputs of the currently
 * selected node, or a read-only preview of a component hovered/clicked in the
 * palette when no node is selected.
 *
 * @module components/PropertiesPanel/PropertiesPanel
 */

import React from 'react'
import { useWorkflow } from '../../context/WorkflowContext'
import './PropertiesPanel.css'

/**
 * Clickable "?" hint that toggles a description popover on click.
 *
 * @param {Object} props
 * @param {string} props.description - Parameter description shown in the popover; renders nothing when falsy
 * @returns {JSX.Element|null}
 */
// Clickable "?" hint that toggles a description popover on click
function ParameterHint({ description }) {
  const [open, setOpen] = React.useState(false)
  if (!description) return null
  return (
    <span className="parameter-hint-wrapper">
      <button
        type="button"
        className={`parameter-hint${open ? ' active' : ''}`}
        aria-expanded={open}
        aria-label="Show parameter description"
        onClick={() => setOpen(o => !o)}
      >
        ?
      </button>
      {open && (
        <span className="parameter-hint-popover" role="tooltip">
          {description}
        </span>
      )}
    </span>
  )
}

/**
 * External-link icon that opens the component's github_io documentation page
 * in a new browser tab. Rendered in the upper-right corner of the component
 * name; renders nothing when no url is provided.
 *
 * @param {Object} props
 * @param {string} props.url - The github_io documentation URL; renders nothing when falsy
 * @returns {JSX.Element|null}
 */
// External-link icon linking to the component's github_io documentation page
function GithubIoLink({ url }) {
  if (!url) return null
  return (
    <a
      className="github-io-link"
      href={url}
      target="_blank"
      rel="noopener noreferrer"
      aria-label="Open component documentation in a new tab"
      title="Open component documentation"
    >
      <svg
        width="16"
        height="16"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
      >
        <path d="M14 4h6v6" />
        <line x1="10" y1="14" x2="20" y2="4" />
        <path d="M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" />
      </svg>
    </a>
  )
}

/**
 * Determine a human-readable datatype string from a variable type structure,
 * recursing through list wrappers and summarizing dict variants.
 *
 * @param {Object} varType - Variable type descriptor (primitive, dict, or list shaped)
 * @returns {string} The resolved datatype (e.g. 'integer', 'dict[real]', 'list[real]', or 'unknown')
 */
// Helper to determine datatype from variable type structure
const getDataType = (varType) => {
  if (!varType) return 'unknown'
  // PrimitiveVariableType: has 'datatype' directly
  if (varType.datatype) return varType.datatype
  // DictVariableType: has 'variables' (array or single object)
  if (varType.variables) {
    if (Array.isArray(varType.variables)) {
      const innerTypes = varType.variables.map(v => v.datatype || 'unknown')
      const uniqueTypes = [...new Set(innerTypes)]
      return `dict[${uniqueTypes.join(', ')}]`
    } else {
      // Single object (legacy format)
      const innerType = varType.variables.datatype || 'unknown'
      return `dict[${innerType}]`
    }
  }
  // ListVariableType: has 'variable' (singular)
  if (varType.variable) return `list[${getDataType(varType.variable)}]`
  return 'unknown'
}

/**
 * Find the display name of a variable, descending through list wrappers.
 *
 * @param {Object} varType - Variable type descriptor
 * @returns {string} The variable's name, or 'unknown' if none is found
 */
// Find the display name of a variable, descending through list wrappers
const getVariableName = (varType) => {
  if (!varType) return 'unknown'
  if (varType.name) return varType.name
  if (varType.variable) return getVariableName(varType.variable)
  return 'unknown'
}

/**
 * Normalize a data type spec (null, single variable, dict {variables},
 * list {variable}, or legacy array) into a list of {name, type} entries.
 *
 * @param {Object|Array} dataType - Data type descriptor to normalize
 * @returns {Array<{name: string, type: string}>} List of display entries for rendering
 */
// Normalize a data type spec (null, single variable, dict {variables},
// list {variable}, or legacy array) into a list of {name, type} entries
const getVariableEntries = (dataType) => {
  if (!dataType) return []
  if (Array.isArray(dataType)) {
    return dataType.map(v => ({ name: getVariableName(v), type: getDataType(v) }))
  }
  if (Array.isArray(dataType.variables)) {
    return dataType.variables.map(v => ({ name: getVariableName(v), type: getDataType(v) }))
  }
  return [{ name: getVariableName(dataType), type: getDataType(dataType) }]
}

/**
 * Modal editor for long text/expression parameters (e.g. IV/DV declarations),
 * which don't fit comfortably in the inline single-line field. Edits a local
 * draft seeded from the current value; the change is only pushed to the node
 * when the user clicks "Update". Cancel, the close button, the Escape key, and
 * clicking the backdrop all discard the draft.
 *
 * @param {Object} props
 * @param {Object} props.param - Parameter descriptor (name, description, default)
 * @param {string} props.initialValue - The parameter's current value
 * @param {function(string): void} props.onUpdate - Commit the edited value
 * @param {function(): void} props.onClose - Dismiss without committing
 * @returns {JSX.Element}
 */
function ExpressionEditorModal({ param, initialValue, onUpdate, onClose }) {
  const [draft, setDraft] = React.useState(initialValue ?? '')
  const panelRef = React.useRef(null)

  // Move focus into the modal on open. (aria-modal alone does not contain focus.)
  React.useEffect(() => {
    const target = panelRef.current?.querySelector('textarea') ||
      panelRef.current?.querySelector('button')
    target?.focus()
  }, [])

  // Keep focus inside the modal: Escape closes; Tab/Shift+Tab wrap at the edges
  // so focus cannot land on the underlying page controls.
  const handleKeyDown = (e) => {
    if (e.key === 'Escape') { onClose(); return }
    if (e.key !== 'Tab' || !panelRef.current) return
    const focusable = Array.from(
      panelRef.current.querySelectorAll('button, textarea, input, select, [href], [tabindex]:not([tabindex="-1"])')
    ).filter(el => !el.disabled)
    if (focusable.length === 0) return
    const first = focusable[0]
    const last = focusable[focusable.length - 1]
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault()
      last.focus()
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault()
      first.focus()
    }
  }

  return (
    <div
      className="expr-editor-overlay"
      onMouseDown={onClose}
      onKeyDown={handleKeyDown}
    >
      <div
        className="expr-editor"
        role="dialog"
        aria-modal="true"
        aria-label={`Edit ${param.name}`}
        ref={panelRef}
        onMouseDown={(e) => e.stopPropagation()}
      >
        <div className="expr-editor-header">
          <h3 className="expr-editor-title">Edit <code>{param.name}</code></h3>
          <button
            type="button"
            className="expr-editor-close"
            aria-label="Close editor"
            onClick={onClose}
          >
            ×
          </button>
        </div>
        {param.description && (
          <p className="expr-editor-description">{param.description}</p>
        )}
        <textarea
          className="expr-editor-textarea"
          value={draft}
          spellCheck={false}
          aria-label={`Value for ${param.name}`}
          placeholder={param.default != null ? param.default.toString() : ''}
          onChange={(e) => setDraft(e.target.value)}
        />
        <div className="expr-editor-actions">
          <button type="button" className="expr-editor-cancel" onClick={onClose}>
            Cancel
          </button>
          <button type="button" className="expr-editor-update" onClick={() => onUpdate(draft)}>
            Update
          </button>
        </div>
      </div>
    </div>
  )
}

/**
 * Properties panel component. Reads the workflow state from context and renders
 * one of three views: the selected node's editable properties, a read-only
 * preview of a palette component, or an empty-state prompt. Defines local
 * helpers for parameter editing, value parsing, and input rendering.
 *
 * @returns {JSX.Element}
 */
function PropertiesPanel() {
  const { state, dispatch } = useWorkflow()
  const selectedNode = state.nodes.find(n => n.id === state.selectedNodeId)
  const previewedComponent = state.previewedComponent

  // The parameter currently being edited in the expanded modal editor, if any.
  const [editorParam, setEditorParam] = React.useState(null)
  // The expand button that opened the modal, so focus can be restored on close.
  const editorTriggerRef = React.useRef(null)
  // Close the modal if the selection changes, so an Update can't land on the
  // wrong node.
  React.useEffect(() => { setEditorParam(null) }, [state.selectedNodeId])

  /**
   * Close the modal editor and restore focus to the expand button that opened it.
   *
   * @returns {void}
   */
  const closeEditor = () => {
    setEditorParam(null)
    editorTriggerRef.current?.focus()
  }

  /**
   * Dispatch an update to a single parameter on the selected node.
   *
   * @param {string} paramName - Name of the parameter to update
   * @param {*} value - New value for the parameter
   * @returns {void}
   */
  const handleParameterChange = (paramName, value) => {
    dispatch({
      type: 'UPDATE_NODE',
      payload: {
        id: selectedNode.id,
        parameters: { ...selectedNode.parameters, [paramName]: value }
      }
    })
  }

  /**
   * Coerce a raw input string into the correct JS type for the given datatype.
   *
   * @param {string} value - Raw value from the input element
   * @param {string} datatype - Target datatype ('integer', 'real', 'boolean', or other)
   * @returns {number|boolean|string|null} Parsed value, or null when the input is empty
   */
  const parseValue = (value, datatype) => {
    if (value === '' || value === null || value === undefined) return null
    switch (datatype) {
      case 'integer':
        return parseInt(value, 10)
      case 'real':
        return parseFloat(value)
      case 'boolean':
        return value === 'true' || value === true
      default:
        return value
    }
  }

  /**
   * Render the appropriate input control for a parameter based on its datatype
   * (number, boolean/categorical select, or text).
   *
   * @param {Object} param - Parameter descriptor with name, datatype, default, and validValues
   * @returns {JSX.Element} The input or select element for editing the parameter
   */
  const renderInput = (param) => {
    const currentValue = selectedNode.parameters[param.name]

    switch (param.datatype) {
      case 'integer':
        return (
          <input
            type="number"
            step="1"
            value={currentValue ?? ''}
            onChange={(e) => handleParameterChange(param.name, parseValue(e.target.value, 'integer'))}
            placeholder={param.default?.toString() || ''}
          />
        )
      case 'real':
        return (
          <input
            type="number"
            step="any"
            value={currentValue ?? ''}
            onChange={(e) => handleParameterChange(param.name, parseValue(e.target.value, 'real'))}
            placeholder={param.default?.toString() || ''}
          />
        )
      case 'boolean':
        return (
          <select
            value={currentValue?.toString() ?? ''}
            onChange={(e) => handleParameterChange(param.name, parseValue(e.target.value, 'boolean'))}
          >
            <option value="">Default</option>
            <option value="true">True</option>
            <option value="false">False</option>
          </select>
        )
      case 'categorical':
        return (
          <select
            value={currentValue ?? ''}
            onChange={(e) => handleParameterChange(param.name, e.target.value)}
          >
            <option value="">Select...</option>
            {(param.validValues || []).map(val => (
              <option key={val} value={val}>{val}</option>
            ))}
          </select>
        )
      case 'IV':
      case 'DV':
        // IV/DV declarations are long Python literals; offer an expand button
        // that opens the larger modal editor alongside the inline field.
        return (
          <div className="expression-field">
            <input
              type="text"
              value={currentValue ?? ''}
              onChange={(e) => handleParameterChange(param.name, e.target.value)}
              placeholder={param.default?.toString() || ''}
            />
            <button
              type="button"
              className="expression-expand-btn"
              aria-label={`Open a larger editor for ${param.name}`}
              title="Open larger editor"
              onClick={(e) => { editorTriggerRef.current = e.currentTarget; setEditorParam(param) }}
            >
              ⤢
            </button>
          </div>
        )
      default:
        return (
          <input
            type="text"
            value={currentValue ?? ''}
            onChange={(e) => handleParameterChange(param.name, e.target.value)}
            placeholder={param.default?.toString() || ''}
          />
        )
    }
  }

  // Show previewed component when no node is selected
  if (!selectedNode && previewedComponent) {
    const previewParams = Object.values(previewedComponent.parameters || {}).flat()
    const previewInputs = getVariableEntries(previewedComponent.inputDataType)
    const previewOutputs = getVariableEntries(previewedComponent.outputDataType)
    return (
      <aside className="properties-panel">
        <div className="properties-header">
          <h2>Component Preview</h2>
        </div>
        <div className="properties-content">
          <div className="property-section">
            <div className="node-info">
              <GithubIoLink url={previewedComponent.github_io} />
              <h3 className="node-info-name">{previewedComponent.name}</h3>
              <span className="node-info-type">{previewedComponent.protocolType?.replace('_', ' ')}</span>
            </div>
            {previewedComponent.description && (
              <p className="node-description">{previewedComponent.description}</p>
            )}
          </div>

          {previewParams.length > 0 && (
            <div className="property-section">
              <h4 className="section-title">Parameters</h4>
              <div className="parameters-list">
                {previewParams.map(param => (
                  <div key={param.name} className="parameter-row preview-only">
                    <label className="parameter-label">
                      {param.name}
                      <ParameterHint description={param.description} />
                    </label>
                    <div className="parameter-value">
                      <span className="parameter-datatype">{param.datatype}</span>
                    </div>
                    {param.default !== undefined && param.default !== null && (
                      <span className="parameter-default">
                        Default: {param.default.toString()}
                      </span>
                    )}
                  </div>
                ))}
              </div>
            </div>
          )}

          {previewInputs.length > 0 && (
            <div className="property-section">
              <h4 className="section-title">Inputs</h4>
              <div className="data-types">
                {previewInputs.map((input, idx) => (
                  <div key={idx} className="data-type-item">
                    <span className="data-type-name">{input.name}</span>
                    <span className="data-type-type">{input.type}</span>
                  </div>
                ))}
              </div>
            </div>
          )}

          {previewOutputs.length > 0 && (
            <div className="property-section">
              <h4 className="section-title">Outputs</h4>
              <div className="data-types">
                {previewOutputs.map((output, idx) => (
                  <div key={idx} className="data-type-item">
                    <span className="data-type-name">{output.name}</span>
                    <span className="data-type-type">{output.type}</span>
                  </div>
                ))}
              </div>
            </div>
          )}

          <div className="preview-hint">
            <p>Drag this component to the canvas to use it</p>
          </div>
        </div>
      </aside>
    )
  }

  if (!selectedNode) {
    return (
      <aside className="properties-panel">
        <div className="properties-empty">
          <div className="empty-icon">📋</div>
          <h3>No Selection</h3>
          <p>Select a node or click a component to view its properties</p>
        </div>
      </aside>
    )
  }

  const parameters = Object.values(selectedNode.componentData?.parameters || {}).flat()
  const nodeInputs = getVariableEntries(selectedNode.componentData?.inputDataType)
  const nodeOutputs = getVariableEntries(selectedNode.componentData?.outputDataType)
  const isFilterNode = selectedNode.type === 'filter_point'

  /**
   * Dispatch an update to a single filter parameter on the selected filter node.
   *
   * @param {string} paramName - Name of the filter parameter to update
   * @param {*} value - New value for the filter parameter
   * @returns {void}
   */
  const handleFilterParameterChange = (paramName, value) => {
    dispatch({
      type: 'UPDATE_NODE',
      payload: {
        id: selectedNode.id,
        filterParams: { ...selectedNode.filterParams, [paramName]: value }
      }
    })
  }

  return (
    <aside className="properties-panel">
      <div className="properties-header">
        <h2>Properties</h2>
      </div>
      <div className="properties-content">
        <div className="property-section">
          <div className="node-info">
            <GithubIoLink url={selectedNode.componentData?.github_io} />
            <h3 className="node-info-name">{selectedNode.name}</h3>
            <span className="node-info-type">{selectedNode.type.replace('_', ' ')}</span>
          </div>
          {selectedNode.description && (
            <p className="node-description">{selectedNode.description}</p>
          )}
          <button
            className="delete-node-btn"
            onClick={() => dispatch({ type: 'DELETE_NODE', payload: selectedNode.id })}
          >
            Delete Node
          </button>
        </div>

        {isFilterNode && (
          <div className="property-section">
            <h4 className="section-title">Filter Settings</h4>
            <div className="parameters-list">
              <div className="parameter-row">
                <label className="parameter-label">
                  Max Counter
                  <ParameterHint description="Maximum number of loop iterations before taking the alternative path" />
                </label>
                <div className="parameter-input">
                  <input
                    type="number"
                    min="1"
                    step="1"
                    value={selectedNode.filterParams?.maxCounter ?? ''}
                    onChange={(e) => {
                      const val = e.target.value
                      handleFilterParameterChange('maxCounter', val === '' ? '' : parseInt(val, 10))
                    }}
                    onBlur={(e) => {
                      const val = parseInt(e.target.value, 10)
                      if (!val || val < 1) {
                        handleFilterParameterChange('maxCounter', 1)
                      }
                    }}
                  />
                </div>
                <span className="parameter-default">Default: 1</span>
              </div>
            </div>
          </div>
        )}

        {parameters.length > 0 && (
          <div className="property-section">
            <h4 className="section-title">Parameters</h4>
            <div className="parameters-list">
              {parameters.map(param => (
                <div key={param.name} className="parameter-row">
                  <label className="parameter-label">
                    {param.name}
                    <ParameterHint description={param.description} />
                  </label>
                  <div className="parameter-input">
                    {renderInput(param)}
                  </div>
                  {param.default !== undefined && param.default !== null && (
                    <span className="parameter-default">
                      Default: {param.default.toString()}
                    </span>
                  )}
                </div>
              ))}
            </div>
          </div>
        )}

        {nodeInputs.length > 0 && (
          <div className="property-section">
            <h4 className="section-title">Inputs</h4>
            <div className="data-types">
              {nodeInputs.map((input, idx) => (
                <div key={idx} className="data-type-item">
                  <span className="data-type-name">{input.name}</span>
                  <span className="data-type-type">{input.type}</span>
                </div>
              ))}
            </div>
          </div>
        )}

        {nodeOutputs.length > 0 && (
          <div className="property-section">
            <h4 className="section-title">Outputs</h4>
            <div className="data-types">
              {nodeOutputs.map((output, idx) => (
                <div key={idx} className="data-type-item">
                  <span className="data-type-name">{output.name}</span>
                  <span className="data-type-type">{output.type}</span>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>

      {editorParam && (
        <ExpressionEditorModal
          param={editorParam}
          initialValue={selectedNode.parameters[editorParam.name]}
          onUpdate={(value) => {
            handleParameterChange(editorParam.name, value)
            closeEditor()
          }}
          onClose={closeEditor}
        />
      )}
    </aside>
  )
}

export default PropertiesPanel