import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
  HF_AUDIO_FX_ATTR,
  serializeAudioFxChain,
  type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import { classifyAudioName } from "@hyperframes/core/audio-carve";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
import { isCanaryEnabled } from "../../telemetry/canary";
import { TimelineFxButton } from "./TimelineFxButton";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { groupAutomationLanes } from "./automationLaneData";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import {
  resolveLaneHeaderState,
  type KeyframeNavigationState,
  type TimelinePropertyLane,
} from "./trackHeaderLaneState";
import { valueReadout } from "./trackHeaderLaneValues";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";

/** Accent rail + inset marking a row as a group MEMBER, matching the level-2
 *  nesting its `aria-level` already reports. */
const GROUP_MEMBER_RAIL = "#3CE6AC59";
const GROUP_MEMBER_INDENT = 14;

interface TimelineTrackHeaderProps {
  /** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
   *  never shown or announced. */
  trackNumber: number;
  /** The track's 1-based position in the rendered order: the only number safe
   *  to put in a label. Announcing `trackNumber` read out "track
   *  0.16666666666666666". Null when the key has no row, which drops the number
   *  from the label rather than inventing one (see trackDisplayNumber). */
  trackDisplayNumber: number | null;
  trackLabel: string;
  /** Ids of the canvas-side lane regions the disclosure caret expands, space
   *  separated as `aria-controls` takes them: the active clip's keyframe lanes
   *  and the track's automation lanes are separate elements, one owned by a
   *  clip and one by the row. Minted by TimelineLanes, the one place that sees
   *  every subtree. */
  lanesId: string;
  contentOrigin: number;
  /** The track's active keyframe clip (selected, else primary) — the one whose
   *  disclosure + property rows this header shows, whether expanded or not. */
  keyframeClip: TimelineElement | null;
  /** Every clip on this track. Automation rows are the track's, unioned over
   *  these, so they stop changing with the selection. */
  trackElements: readonly TimelineElement[];
  /** Clips on this track, so the header can say how many the row holds. */
  clipCount: number;
  isExpanded: boolean;
  animations: readonly GsapAnimation[];
  currentTime: number;
  isTrackHidden: boolean;
  isAudioTrack: boolean;
  /** This track is a member of an audio group — indents the row under its header. */
  isGroupMember?: boolean;
  rovingTargetId?: string | null;
  theme: TimelineTheme;
  onToggleClipExpanded: () => void;
  onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
  /** Drop one envelope. Absent while the lanes are read-only, which is what
   *  hides the control rather than offering a button that cannot act. */
  onRemoveAutomationLane?: (target: string) => void;
  onSeek?: (time: number) => void;
}

// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
function PropertyGroupNavigation({
  navigation,
  label,
  expandedElement,
  onSeek,
  children,
}: {
  navigation: KeyframeNavigationState;
  label: string;
  expandedElement: TimelineElement;
  onSeek?: (time: number) => void;
  children: React.ReactNode;
}) {
  // The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
  // target is met with a centered transparent ::before overlay instead of a
  // bigger box; focus-visible matches every other control in this header.
  const CHEVRON_BUTTON_CLASS =
    "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
    "focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
    "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
    "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
  const seekTo = (keyframe: { percentage: number } | null) => {
    if (keyframe) {
      onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
    }
  };
  return (
    <span className="flex shrink-0 items-center gap-0.5">
      <button
        type="button"
        aria-label={`Previous ${label} keyframe`}
        disabled={!navigation.prevKeyframe}
        className={CHEVRON_BUTTON_CLASS}
        onClick={(event) => {
          event.stopPropagation();
          seekTo(navigation.prevKeyframe);
        }}
      >
        ‹
      </button>
      {children}
      <button
        type="button"
        aria-label={`Next ${label} keyframe`}
        disabled={!navigation.nextKeyframe}
        className={CHEVRON_BUTTON_CLASS}
        onClick={(event) => {
          event.stopPropagation();
          seekTo(navigation.nextKeyframe);
        }}
      >
        ›
      </button>
    </span>
  );
}

function PropertyGroupHeaderRow({
  lanesId,
  lane,
  laneIndex,
  isLastLane,
  expandedElement,
  currentTime,
  clipPercentage,
  gutterBackground,
  columnWidth,
  onTogglePropertyGroupKeyframe,
  onSeek,
  rovingTargetId = null,
}: {
  lanesId: string;
  lane: TimelinePropertyLane;
  laneIndex: number;
  isLastLane: boolean;
  expandedElement: TimelineElement;
  currentTime: number;
  clipPercentage: number;
  gutterBackground: string;
  columnWidth: number;
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
  onSeek?: (time: number) => void;
  rovingTargetId: string | null;
}) {
  const elementId = expandedElement.key ?? expandedElement.id;
  const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
    lane,
    currentTime,
    clipPercentage,
  );

  return (
    <div
      id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
      data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
      data-timeline-element-id={elementId}
      tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
      data-property-group={lane.group}
      data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
      className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
      style={{
        top: getTimelineLaneTop(laneIndex),
        // The header column narrows to contentOrigin whenever that is under
        // LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
        // readout over the canvas, on top of the clips it is labelling.
        width: columnWidth,
        height: LANE_H,
        background: gutterBackground,
      }}
    >
      {/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
      <span className="relative h-full w-3 shrink-0" aria-hidden="true">
        <span
          className="absolute left-1.5 top-0 w-px bg-white/15"
          style={{ height: isLastLane ? "50%" : "100%" }}
        />
        <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
      </span>
      <span className="w-[46px] shrink-0 truncate text-white" title={label}>
        {label}
      </span>
      <PropertyGroupNavigation
        navigation={navigation}
        label={label}
        expandedElement={expandedElement}
        onSeek={onSeek}
      >
        <button
          type="button"
          aria-pressed={!!navigation.currentKeyframe}
          aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
          title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
          // h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
          className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
          onClick={(event) => {
            // Same as the disclosure caret and the eye: a control in the label
            // column owns its click, it does not also hit the track row behind it.
            event.stopPropagation();
            if (toggleTarget) {
              void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
            }
          }}
        >
          {navigation.currentKeyframe ? "◆" : "◇"}
        </button>
      </PropertyGroupNavigation>
      <span
        className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
        title={valueReadout(lane.group, values)}
      >
        {valueReadout(lane.group, values)}
      </span>
    </div>
  );
}

/**
 * One envelope's row in the label column.
 *
 * Named here rather than inside the lane, on the same tree connector the
 * keyframe rows use: an automation lane is a child of its clip exactly as a
 * property group is, and drawing its name over the envelope put the label on top
 * of the curve it describes and scrolled it away from its own row.
 */
function AutomationLaneHeaderRow({
  target,
  label,
  name,
  param,
  top,
  isLastLane,
  gutterBackground,
  columnWidth,
  onRemove,
}: {
  /** The lane the ACTIVE clip draws in this row, or null when it draws none —
   *  the row belongs to the property, and a clip may be absent from it. */
  target: string | null;
  /** The whole thing on one line: the row's identity, its tooltip, and the
   *  remove button's name. */
  label: string;
  /** What the effect is — "Peaking EQ 1.6 kHz". */
  name: string;
  /** Which knob the envelope drives. Empty when there is no second line to draw. */
  param: string;
  top: number;
  isLastLane: boolean;
  gutterBackground: string;
  columnWidth: number;
  onRemove?: (target: string) => void;
}) {
  return (
    <div
      data-automation-lane-label={label}
      data-timeline-lane-top={top}
      className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
      style={{
        top,
        width: columnWidth,
        height: AUTOMATION_LANE_H,
        background: gutterBackground,
      }}
    >
      {/* Tree connector, as the keyframe rows draw it: spine down the row, branch
          tick at the name's own height. */}
      <span className="relative h-full w-3 shrink-0" aria-hidden="true">
        <span
          className="absolute left-1.5 top-0 w-px bg-white/15"
          style={{ height: isLastLane ? "50%" : "100%" }}
        />
        <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
      </span>
      {/* Two lines: what the effect is, then which knob the envelope drives. On
          one line a band's own name was the first thing truncated in a column this
          narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
          bands apart. */}
      <span className="flex min-w-0 flex-1 flex-col justify-center leading-tight" title={label}>
        <span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
          {name}
        </span>
        {param ? (
          <span
            data-automation-lane-param=""
            className="truncate font-mono text-[9px] text-white/40"
          >
            {param}
          </span>
        ) : null}
      </span>
      {/* Beside the name it labels, because that is the only place an envelope is
          named at all: a carve writes its own lanes, and the FX panel's automate
          toggle can only reach a parameter it still lists — so without this an
          envelope could be created and never removed.

          Only for the clip the header is showing, since that is the only one a
          write can reach: a shared row's other envelopes belong to clips that
          are not selected, and a button that silently removed one of them (or
          none) would be worse than no button. */}
      {onRemove && target !== null && (
        <button
          type="button"
          aria-label={`Remove ${label} automation`}
          title={`Remove ${label} automation`}
          // h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
          className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
          onPointerDown={(event) => event.stopPropagation()}
          onClick={(event) => {
            // A control in the label column owns its click; it does not also hit
            // the track row behind it and change the selection.
            event.stopPropagation();
            onRemove(target);
          }}
        >
          ×
        </button>
      )}
    </div>
  );
}

// fallow-ignore-next-line complexity
export function TimelineTrackHeader({
  trackNumber,
  trackDisplayNumber,
  trackLabel,
  lanesId,
  contentOrigin,
  keyframeClip,
  trackElements,
  clipCount,
  isExpanded,
  animations,
  currentTime,
  isTrackHidden,
  isAudioTrack,
  isGroupMember = false,
  theme,
  onToggleClipExpanded,
  onToggleTrackHidden,
  onTogglePropertyGroupKeyframe,
  onRemoveAutomationLane,
  onSeek,
  rovingTargetId = null,
}: TimelineTrackHeaderProps) {
  const clipPercentage = keyframeClip
    ? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
    : 0;
  const lanes = keyframeClip
    ? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
      // host-absolute while its tweens are local to its own file.
      getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
    : [];
  // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
  // owns the gutter past it, so a 0% diamond isn't clipped by this panel).
  const showTrackLabel = contentOrigin >= LABEL_COL_W;
  // One row per automated property across the whole track, in the order the
  // canvas draws them — a name beside the wrong envelope is worse than an awkward
  // order. `target` is the ACTIVE clip's lane in that row, which is the only one
  // the remove button can write to; null when the row belongs to its siblings.
  const activeKey = keyframeClip ? (keyframeClip.key ?? keyframeClip.id) : null;
  const automationRows = groupAutomationLanes(trackElements).map((group) => ({
    key: group.key,
    label: group.key,
    name: group.name,
    param: group.param,
    target:
      group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane
        .target ?? null,
  }));
  // Automation counts as something to disclose: gating the caret on tweens alone
  // left an audio clip's envelopes unreachable, since the track could not expand.
  const disclosable = lanes.length > 0 || automationRows.length > 0;
  const isKeyframeLayer = !!keyframeClip && disclosable;
  // Solo is per-clip/per-group, never per track (design doc §2.2) — this header
  // acts on the track's first clip as a pragmatic stand-in for "this track",
  // the same simplification the mute button doesn't need to make (it patches
  // every clip on the track at once).
  // A bare DOM id, not the store key: the set lands in the runtime, which
  // compares it against `el.id` (see `runtimeAudioId`). A track whose first
  // clip has no DOM id simply has no solo button.
  const soloTargetId = trackElements[0] ? runtimeAudioId(trackElements[0]) : null;
  const soloed = usePlayerStore((s) => s.soloed);
  const toggleSolo = usePlayerStore((s) => s.toggleSolo);

  // C1: the FX entry point. A single audio clip has one chain to point at; a
  // track holding several ungrouped ones has no single chain — the design
  // doc refuses to build "N clips = N chains", so that case gets a pointer
  // at grouping (B6's normative rule) instead of a popover.
  const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
    useTimelineEditContextOptional();
  const domEditActions = useDomEditActionsContextOptional();
  const singleAudioClip =
    isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
  const isTrackGrouped = trackElements.some((el) => el.audioGroup);
  const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
    const value = next.nodes.length ? serializeAudioFxChain(next) : null;
    if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
    else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
  };
  const openClipFxRack = (clip: TimelineElement) => {
    void domEditActions?.handleTimelineElementSelect(clip);
  };
  // DOM ids, matching the carve picker's other caller — membership is read back
  // by `resolveAudioGroups`, which only ever sees the document. A clip with no
  // DOM id cannot be a member (resolveAudioGroups skips it), so a track holding
  // one cannot be grouped WHOLE — and grouping the rest would quietly leave
  // those clips outside the bus, past every fader, mute and effect, while the
  // UI showed the track as grouped. The button is withheld instead of acting on
  // a subset, which is also why the carve path's loud guard cannot catch this:
  // the unresolvable ids were filtered out before the call.
  const groupableClipIds = trackElements.map(runtimeAudioId);
  const canGroupWholeTrack =
    groupableClipIds.length >= 2 && groupableClipIds.every((id) => id !== null);
  const groupUngroupedClips = () => {
    const doc = domEditActions?.previewIframeRef.current?.contentDocument;
    if (!doc || !onGroupClips) return;
    if (!canGroupWholeTrack) return;
    void onGroupClips(groupableClipIds as string[], mintGroupId(doc));
  };

  return (
    <div
      role="rowheader"
      aria-colindex={1}
      className={`sticky left-0 z-[12] shrink-0 ${
        !isKeyframeLayer
          ? showTrackLabel
            ? "flex items-center gap-1 px-1.5 text-white/55"
            : "flex flex-col items-center justify-center gap-0.5"
          : ""
      }`}
      style={{
        width: showTrackLabel ? LABEL_COL_W : contentOrigin,
        background: theme.gutterBackground,
        borderRight: `1px solid ${theme.gutterBorder}`,
        // A group's member rows are `aria-level="2"`, and until this they read
        // as level 2 to a screen reader while looking identical to every
        // top-level row on screen. The rail is the accent-tinted left border
        // B2's design called for; the inset is what actually makes the nesting
        // legible. Padding rather than margin so the rail stays flush with the
        // gutter's own edge.
        ...(isGroupMember
          ? {
              borderLeft: `2px solid ${GROUP_MEMBER_RAIL}`,
              paddingLeft: GROUP_MEMBER_INDENT,
            }
          : {}),
      }}
    >
      {!keyframeClip || !disclosable ? (
        <>
          <PlainTrackHeader
            trackNumber={trackNumber}
            trackDisplayNumber={trackDisplayNumber}
            trackLabel={trackLabel}
            clipCount={clipCount}
            showTrackLabel={showTrackLabel}
            isTrackHidden={isTrackHidden}
            isAudioTrack={isAudioTrack}
            isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
            isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
            onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
            onToggleTrackHidden={onToggleTrackHidden}
          />
          {singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
            <TimelineFxButton
              variant="chain"
              fxChainRaw={singleAudioClip.fxChain}
              trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
              onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
              onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
              // Muted, an audition is silent — so the hover lifts the mute on
              // the running graph and puts it back on the way out, the same
              // borrow-and-return it already does with the playhead.
              auditionSpans={[singleAudioClip]}
              isMuted={isTrackHidden}
              onSetMutedLive={(muted) =>
                onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null)
              }
              onOpenRack={() => openClipFxRack(singleAudioClip)}
            />
          )}
          {/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES
              a group, so it needs `audio-groups` too — without it a user outside
              that canary could create a group and then have no UI to manage it. */}
          {isAudioTrack &&
            clipCount > 1 &&
            !isTrackGrouped &&
            canGroupWholeTrack &&
            isCanaryEnabled("audio-fx-rack") &&
            isCanaryEnabled("audio-groups") && (
              <TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
            )}
        </>
      ) : (
        <>
          <LayerDisclosureRow
            name={
              // A row holding several clips is named for the track, not for
              // whichever of them is selected — the lanes below it are the
              // track's, shared per property, and naming it "Narration 2" read
              // as if they all belonged to that one slice.
              clipCount > 1
                ? `Track${trackDisplaySuffix(trackDisplayNumber)}`
                : (keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id)
            }
            clipCount={clipCount}
            isExpanded={isExpanded}
            gutterBackground={theme.gutterBackground}
            columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
            lanesId={lanesId}
            onToggleClipExpanded={onToggleClipExpanded}
          >
            {/* The eye belongs to the LAYER, so it lives on the always-mounted
                layer row exactly like a plain track's. Hanging it off a lane row
                (hover-gated, and only while expanded) left a keyframed track with
                no way to be hidden at all by keyboard, and put the control on a
                row it does not act on. */}
            <VisibilityButton
              hidden={isTrackHidden}
              trackNumber={trackNumber}
              trackDisplayNumber={trackDisplayNumber}
              visible
              isAudioTrack={isAudioTrack}
              onToggle={onToggleTrackHidden}
            />
          </LayerDisclosureRow>
          {/* The caret expands TWO disjoint subtrees: these label-column rows,
              which carry the per-lane keyframe controls, and the diamond lanes
              on the canvas. `lanesId` names the canvas lanes (rendered by
              TimelineLanes), because that is what a sighted user watches appear
              and what following the reference has to land on. These rows are not
              empty and are not the target; they are absolutely positioned inside
              the sticky column, which is what made a wrapper HERE compute to
              0x0 and hold no diamonds. */}
          {isExpanded &&
            lanes.map((lane, laneIndex) => (
              <PropertyGroupHeaderRow
                key={lane.group}
                lanesId={lanesId}
                lane={lane}
                laneIndex={laneIndex}
                isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
                expandedElement={keyframeClip}
                currentTime={currentTime}
                clipPercentage={clipPercentage}
                gutterBackground={theme.gutterBackground}
                columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
                onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
                onSeek={onSeek}
                rovingTargetId={rovingTargetId}
              />
            ))}
          {/* Below the keyframe rows and stepping by its own height, which is how
              TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
              two have to agree or a name labels the wrong curve. */}
          {isExpanded &&
            automationRows.map((row, index) => (
              <AutomationLaneHeaderRow
                key={row.key}
                target={row.target}
                label={row.label}
                name={row.name}
                param={row.param}
                top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
                isLastLane={index === automationRows.length - 1}
                gutterBackground={theme.gutterBackground}
                columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
                onRemove={onRemoveAutomationLane}
              />
            ))}
        </>
      )}
    </div>
  );
}
