import type { LintContext, HyperframeLintFinding } from "../context";
import { findHtmlTag, readAttr, readJsonAttr, stripJsComments, truncateSnippet } from "../utils";
import { COMPOSITION_VARIABLE_TYPES } from "../../core.types";

// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
const MAX_COMPOSITION_LINES = 300;
const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]);

function countPhysicalLines(source: string): number {
  if (source.length === 0) return 0;

  const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
  const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
  return withoutFinalNewline.split("\n").length;
}

function countStructuralLines(source: string): number {
  return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
}

export function isRegistrySourceFile(filePath?: string): boolean {
  if (!filePath) return false;

  const normalized = filePath.replace(/\\/g, "/");
  return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
}

export function isRegistryInstalledFile(rawSource: string): boolean {
  return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
}

function isCompositionRootOrMount(rawTag: string): boolean {
  return Boolean(
    readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
  );
}

export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
  // invalid_capture_path — catches ../capture/ in src/href attributes and scripts.
  // Sub-compositions live in compositions/ but are served relative to the project
  // root, so all asset paths must be root-relative ("capture/...").
  // Using "../capture/..." works on disk but breaks in Studio and renders.
  ({ rawSource, options }) => {
    if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
    // Only flag in sub-compositions and root compositions — not in registry blocks
    const matches = rawSource.match(/\.\.\/capture\//g);
    if (!matches || matches.length === 0) return [];
    return [
      {
        code: "invalid_capture_path",
        severity: "error",
        message: `Found ${matches.length} asset path(s) using ../capture/ — will 404 in Studio and renders.`,
        fixHint:
          'Replace all "../capture/" with "capture/" throughout this file. Compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.',
      },
    ];
  },

  // composition_file_too_large
  ({ rawSource, options }) => {
    if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];

    const lineCount = countStructuralLines(rawSource);
    if (lineCount <= MAX_COMPOSITION_LINES) return [];

    const splitTarget = options.isSubComposition
      ? "Split this sub-composition further into smaller .html files"
      : "Split coherent scenes or layers into separate .html files under compositions/";

    return [
      {
        code: "composition_file_too_large",
        severity: "warning",
        message: `This HTML composition file has ${lineCount} lines. Smaller sub-compositions are easier to read, iterate on, and diff.`,
        fixHint: `${splitTarget}, then mount them from the parent with data-composition-src so each file stays small enough to inspect, revise, and validate independently.`,
      },
    ];
  },

  // timeline_track_too_dense
  // fallow-ignore-next-line complexity
  ({ tags, options }) => {
    const trackCounts = new Map<string, number>();
    for (const tag of tags) {
      if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
      if (isCompositionRootOrMount(tag.raw)) continue;
      if (!readAttr(tag.raw, "data-start")) continue;

      const track = readAttr(tag.raw, "data-track-index");
      if (!track) continue;
      trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
    }

    const findings: HyperframeLintFinding[] = [];
    for (const [track, count] of trackCounts) {
      if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
      const splitTarget = options.isSubComposition
        ? "Move coherent scene groups into smaller .html files"
        : "Move coherent scene groups into separate .html files under compositions/";
      findings.push({
        code: "timeline_track_too_dense",
        severity: "warning",
        message: `Track ${track} has ${count} timed elements in this HTML file. Smaller sub-compositions keep timelines easier to read, iterate on, and diff.`,
        fixHint: `${splitTarget} and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.`,
      });
    }

    return findings;
  },

  // timed_element_missing_visibility_hidden
  // fallow-ignore-next-line complexity
  ({ tags }) => {
    const findings: HyperframeLintFinding[] = [];
    for (const tag of tags) {
      if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
      if (!readAttr(tag.raw, "data-start")) continue;
      if (readAttr(tag.raw, "data-composition-id")) continue;
      if (readAttr(tag.raw, "data-composition-src")) continue;
      const classAttr = readAttr(tag.raw, "class") || "";
      const styleAttr = readAttr(tag.raw, "style") || "";
      const hasClip = classAttr.split(/\s+/).includes("clip");
      const hasHiddenStyle =
        /visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
      if (!hasClip && !hasHiddenStyle) {
        const elementId = readAttr(tag.raw, "id") || undefined;
        findings.push({
          code: "timed_element_missing_visibility_hidden",
          severity: "info",
          message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
          elementId,
          fixHint:
            'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
          snippet: truncateSnippet(tag.raw),
        });
      }
    }
    return findings;
  },

  // deprecated_data_layer + deprecated_data_end
  // fallow-ignore-next-line complexity
  ({ tags }) => {
    const findings: HyperframeLintFinding[] = [];
    for (const tag of tags) {
      if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
        const elementId = readAttr(tag.raw, "id") || undefined;
        findings.push({
          code: "deprecated_data_layer",
          severity: "error",
          message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
          elementId,
          fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
          snippet: truncateSnippet(tag.raw),
        });
      }
      if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
        const elementId = readAttr(tag.raw, "id") || undefined;
        findings.push({
          code: "deprecated_data_end",
          severity: "error",
          message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
          elementId,
          fixHint:
            "Replace data-end with data-duration. The compiler generates data-end from data-duration automatically.",
          snippet: truncateSnippet(tag.raw),
        });
      }
    }
    return findings;
  },

  // split_data_attribute_selector
  ({ scripts, styles }) => {
    const findings: HyperframeLintFinding[] = [];
    const splitDataAttrSelectorPattern =
      /\[data-composition-id=(["'])([^"'\]]+)\1\s+(data-[\w:-]+)=(["'])([^"'\]]*)\4\]/g;
    const scan = (content: string) => {
      splitDataAttrSelectorPattern.lastIndex = 0;
      let match: RegExpExecArray | null;
      while ((match = splitDataAttrSelectorPattern.exec(content)) !== null) {
        const compId = match[2] ?? "";
        const attrName = match[3] ?? "";
        const attrValue = match[5] ?? "";
        findings.push({
          code: "split_data_attribute_selector",
          severity: "error",
          message:
            `Selector "${match[0]}" combines two attributes inside one CSS attribute selector. ` +
            "Browsers reject it, so GSAP timelines or querySelector calls will fail before registering.",
          selector: match[0],
          fixHint: `Use separate attribute selectors: [data-composition-id="${compId}"][${attrName}="${attrValue}"].`,
          snippet: truncateSnippet(match[0]),
        });
      }
    };
    for (const style of styles) scan(style.content);
    for (const script of scripts) scan(script.content);
    return findings;
  },

  // template_literal_selector
  ({ scripts }) => {
    const findings: HyperframeLintFinding[] = [];
    for (const script of scripts) {
      const templateLiteralSelectorPattern =
        /(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g;
      let tlMatch: RegExpExecArray | null;
      while ((tlMatch = templateLiteralSelectorPattern.exec(script.content)) !== null) {
        findings.push({
          code: "template_literal_selector",
          severity: "error",
          message:
            "querySelector uses a template literal variable (e.g. `${compId}`). " +
            "The HTML bundler's CSS parser crashes on these. Use a hardcoded string instead.",
          fixHint:
            "Replace the template literal variable with a hardcoded string. The bundler's CSS parser cannot handle interpolated variables in script content.",
          snippet: truncateSnippet(tlMatch[0]),
        });
      }
    }
    return findings;
  },

  // timed_element_missing_clip_class
  // fallow-ignore-next-line complexity
  ({ tags }) => {
    const findings: HyperframeLintFinding[] = [];
    const skipTags = new Set(["audio", "video", "script", "style", "template"]);
    for (const tag of tags) {
      if (skipTags.has(tag.name)) continue;
      // Skip composition hosts
      if (readAttr(tag.raw, "data-composition-id")) continue;
      if (readAttr(tag.raw, "data-composition-src")) continue;

      const hasStart = readAttr(tag.raw, "data-start") !== null;
      const hasDuration = readAttr(tag.raw, "data-duration") !== null;
      // data-track-index alone marks a layer container, not a time-bounded clip
      if (!hasStart && !hasDuration) continue;

      const classAttr = readAttr(tag.raw, "class") || "";
      const hasClip = classAttr.split(/\s+/).includes("clip");
      if (hasClip) continue;

      const elementId = readAttr(tag.raw, "id") || undefined;
      findings.push({
        code: "timed_element_missing_clip_class",
        severity: "error",
        message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
        elementId,
        fixHint:
          'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
        snippet: truncateSnippet(tag.raw),
      });
    }
    return findings;
  },

  // overlapping_clips_same_track
  // fallow-ignore-next-line complexity
  ({ tags }) => {
    const findings: HyperframeLintFinding[] = [];

    type ClipInfo = { start: number; end: number; elementId?: string; snippet: string };
    const trackMap = new Map<string, ClipInfo[]>();

    for (const tag of tags) {
      const startStr = readAttr(tag.raw, "data-start");
      const durationStr = readAttr(tag.raw, "data-duration");
      const trackStr = readAttr(tag.raw, "data-track-index");
      if (!startStr || !durationStr || !trackStr) continue;

      const start = Number(startStr);
      const duration = Number(durationStr);
      const track = trackStr;

      // Skip non-numeric (relative timing references like "intro-comp")
      if (Number.isNaN(start) || Number.isNaN(duration)) continue;

      const clips = trackMap.get(track) || [];
      clips.push({
        start,
        end: start + duration,
        elementId: readAttr(tag.raw, "id") || undefined,
        snippet: truncateSnippet(tag.raw) || "",
      });
      trackMap.set(track, clips);
    }

    for (const [track, clips] of trackMap) {
      clips.sort((a, b) => a.start - b.start);
      for (let i = 0; i < clips.length - 1; i++) {
        const current = clips[i];
        const next = clips[i + 1];
        if (!current || !next) continue;
        if (current.end > next.start) {
          findings.push({
            code: "overlapping_clips_same_track",
            severity: "error",
            message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
            fixHint:
              "Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.",
          });
        }
      }
    }

    return findings;
  },

  // root_composition_missing_data_start
  ({ rootTag, options }) => {
    const findings: HyperframeLintFinding[] = [];
    if (options.isSubComposition) return findings;
    if (!rootTag) return findings;
    const compId = readAttr(rootTag.raw, "data-composition-id");
    if (!compId) return findings;
    const hasStart = readAttr(rootTag.raw, "data-start") !== null;
    if (!hasStart) {
      findings.push({
        code: "root_composition_missing_data_start",
        severity: "error",
        message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
        fixHint: 'Add data-start="0" to the root composition element.',
        snippet: truncateSnippet(rootTag.raw),
      });
    }
    return findings;
  },

  // standalone_composition_wrapped_in_template
  ({ rawSource, options }) => {
    const findings: HyperframeLintFinding[] = [];
    if (options.isSubComposition) return findings;
    const trimmed = rawSource.trimStart().toLowerCase();
    if (trimmed.startsWith("<template")) {
      findings.push({
        code: "standalone_composition_wrapped_in_template",
        severity: "error",
        message:
          "Root index.html is wrapped in a <template> tag. " +
          "Only sub-compositions loaded via data-composition-src should use <template> wrappers. " +
          "The runtime cannot play a standalone composition inside a template.",
        fixHint:
          "Remove the <template> wrapper. Use <!DOCTYPE html><html>...<div data-composition-id>...</div>...</html> instead.",
      });
    }
    return findings;
  },

  // root_composition_missing_html_wrapper
  ({ rawSource, rootTag, options }) => {
    const findings: HyperframeLintFinding[] = [];
    if (options.isSubComposition) return findings;
    const trimmed = rawSource.trimStart().toLowerCase();
    // Compositions inside <template> are caught by standalone_composition_wrapped_in_template
    if (trimmed.startsWith("<template")) return findings;
    const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
    const hasComposition = rawSource.includes("data-composition-id");
    if (hasComposition && !hasDoctype) {
      findings.push({
        code: "root_composition_missing_html_wrapper",
        severity: "error",
        message:
          "Composition starts with a bare element instead of a proper HTML document. " +
          "An index.html that contains data-composition-id but no <!DOCTYPE html>, <html>, or <body> " +
          "is a fragment — browsers quirks-mode it, the preview server cannot load it, and " +
          "the bundler will fail to inject runtime scripts.",
        fixHint:
          'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
        snippet: rootTag ? truncateSnippet(rootTag.raw) : undefined,
      });
    }
    return findings;
  },

  // requestanimationframe_in_composition
  ({ scripts, rawSource, options }) => {
    if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
    const findings: HyperframeLintFinding[] = [];
    for (const script of scripts) {
      const stripped = stripJsComments(script.content);
      if (/requestAnimationFrame\s*\(/.test(stripped)) {
        findings.push({
          code: "requestanimationframe_in_composition",
          severity: "error",
          message:
            "`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.",
          fixHint:
            "Use GSAP tweens or onUpdate callbacks instead of requestAnimationFrame for animation logic.",
          snippet: truncateSnippet(script.content),
        });
      }
    }
    return findings;
  },

  // invalid_variable_values_json
  // Host elements (`[data-composition-src]`) carry per-instance values via
  // `data-variable-values`. The runtime swallows JSON errors silently and
  // falls back to declared defaults, which masks typos. This rule surfaces
  // the parse failure so authors notice before render time.
  // fallow-ignore-next-line complexity
  ({ tags }) => {
    const findings: HyperframeLintFinding[] = [];
    for (const tag of tags) {
      const raw = readJsonAttr(tag.raw, "data-variable-values");
      if (!raw) continue;

      let parsed: unknown;
      try {
        parsed = JSON.parse(raw);
      } catch (err) {
        const reason = err instanceof Error ? err.message : "unknown";
        findings.push({
          code: "invalid_variable_values_json",
          severity: "error",
          message: `data-variable-values is not valid JSON (${reason}).`,
          fixHint:
            'Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values=\'{"title":"Hello"}\'.',
          elementId: readAttr(tag.raw, "id") || undefined,
          snippet: truncateSnippet(tag.raw),
        });
        continue;
      }

      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
        findings.push({
          code: "invalid_variable_values_json",
          severity: "error",
          message:
            'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
          fixHint:
            "Replace the value with a JSON object whose keys are variable ids declared in the sub-composition's data-composition-variables.",
          elementId: readAttr(tag.raw, "id") || undefined,
          snippet: truncateSnippet(tag.raw),
        });
      }
    }
    return findings;
  },

  // invalid_composition_variables_declaration
  // The runtime parses `data-composition-variables` and silently returns []
  // on any structural problem. Surface JSON / shape failures so authors
  // catch them at lint time rather than wondering why their `getVariables()`
  // defaults aren't applied.
  // fallow-ignore-next-line complexity
  ({ source }) => {
    const htmlTag = findHtmlTag(source);
    if (!htmlTag) return [];
    const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
    if (!raw) return [];

    let parsed: unknown;
    try {
      parsed = JSON.parse(raw);
    } catch (err) {
      const reason = err instanceof Error ? err.message : "unknown";
      return [
        {
          code: "invalid_composition_variables_declaration",
          severity: "error",
          message: `data-composition-variables is not valid JSON (${reason}).`,
          fixHint:
            'Provide a JSON array of variable declarations: data-composition-variables=\'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
          snippet: truncateSnippet(htmlTag.raw),
        },
      ];
    }

    if (!Array.isArray(parsed)) {
      return [
        {
          code: "invalid_composition_variables_declaration",
          severity: "error",
          message: "data-composition-variables must be a JSON array of variable declarations.",
          fixHint:
            'Wrap declarations in [] and give each an id, type, label, and default: \'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
          snippet: truncateSnippet(htmlTag.raw),
        },
      ];
    }

    const findings: HyperframeLintFinding[] = [];
    const knownTypes = new Set<string>(COMPOSITION_VARIABLE_TYPES);
    for (let i = 0; i < parsed.length; i += 1) {
      const entry = parsed[i];
      if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
        findings.push({
          code: "invalid_composition_variables_declaration",
          severity: "error",
          message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
          snippet: truncateSnippet(htmlTag.raw),
        });
        continue;
      }
      const e = entry as Record<string, unknown>;
      const missing: string[] = [];
      if (typeof e.id !== "string") missing.push("id");
      if (typeof e.type !== "string" || !knownTypes.has(e.type)) missing.push("type");
      if (typeof e.label !== "string") missing.push("label");
      if (!("default" in e)) missing.push("default");
      if (missing.length > 0) {
        findings.push({
          code: "invalid_composition_variables_declaration",
          severity: "error",
          message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum, font, image.`,
          snippet: truncateSnippet(htmlTag.raw),
        });
      }
    }
    return findings;
  },

  // subcomposition_blanks_before_host
  // Warns when a full-bleed sub-composition slot ends before the host composition
  // does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
  // the high-signal shape — a sole/dominant external mount starting at ~0 — so it
  // stays silent on intentional short clips (an intro followed by other clips that
  // carry the timeline forward).
  // fallow-ignore-next-line complexity
  ({ tags, rootTag }) => {
    if (!rootTag) return [];
    const rootDuration = Number(readAttr(rootTag.raw, "data-duration"));
    if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];

    // Two independent knobs that happen to share a 0.5s magnitude. Tuned for
    // real hosts (tens to hundreds of seconds); on a very short host (~6s) the
    // EPSILON slack would let a ~10% blank tail pass unflagged — acceptable
    // because the silent-blank trap this rule targets only matters at scale.
    const EPSILON = 0.5; // seconds; tolerance for "ends/covers near the host end"
    const START_TOLERANCE = 0.5; // seconds; "starts at the composition start"
    const round3 = (n: number) => Math.round(n * 1000) / 1000;

    // Timed children of the root. An element with data-start but no usable
    // data-duration is treated as covering the tail (end = Infinity), so an
    // unknown-length sibling suppresses the warning rather than triggering it.
    const timed = tags
      .filter((tag) => tag.index !== rootTag.index && readAttr(tag.raw, "data-start") !== null)
      .map((tag) => {
        const start = Number(readAttr(tag.raw, "data-start")) || 0;
        const dur = Number(readAttr(tag.raw, "data-duration"));
        const end = Number.isFinite(dur) && dur > 0 ? start + dur : Infinity;
        return { tag, start, end };
      });

    // `tags` is a flat list (no nesting depth), so a timed element nested
    // *inside* a candidate slot is treated as a tail-covering sibling rather
    // than a descendant. Acceptable: external src mounts are empty by
    // convention (content is loaded from the linked file), so the only
    // false-negative path is rare and matches the flat-tag scope of the
    // sibling rules in this file.
    const tailCovered = (exceptIndex: number) =>
      timed.some((t) => t.tag.index !== exceptIndex && t.end >= rootDuration - EPSILON);

    const findings: HyperframeLintFinding[] = [];
    for (const t of timed) {
      if (readAttr(t.tag.raw, "data-composition-src") === null) continue; // external slot only
      if (t.start > START_TOLERANCE) continue; // must start at the composition start
      if (!Number.isFinite(t.end)) continue; // known, finite slot length
      if (t.end >= rootDuration - EPSILON) continue; // already fills the host window
      if (tailCovered(t.tag.index)) continue; // another clip covers the tail — not full-bleed
      const elementId = readAttr(t.tag.raw, "id") || undefined;
      const gap = round3(rootDuration - t.end);
      findings.push({
        code: "subcomposition_blanks_before_host",
        severity: "warning",
        message: `<${t.tag.name}${elementId ? ` id="${elementId}"` : ""}> sub-composition ends at ${round3(t.end)}s but the composition runs to ${round3(rootDuration)}s — its slot will be blank for ~${gap}s.`,
        elementId,
        fixHint: `data-duration is the slot's visible window. Set this sub-composition's data-duration to ${round3(rootDuration - t.start)} to fill the host window, or add another clip to cover the remaining ~${gap}s.`,
        snippet: truncateSnippet(t.tag.raw),
      });
    }
    return findings;
  },
];
