import type { RelayEvent } from "@/shared/api/types";
import {
  KIND_PROJECT_ANNOUNCEMENT,
  KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
import { effectiveCloneUrls } from "./lib/projectCloneUrl";

export type Repository = {
  id: string;
  dtag: string;
  name: string;
  description: string;
  cloneUrls: string[];
  webUrl: string | null;
  owner: string;
  contributors: string[];
  createdAt: number;
  status: string;
  defaultBranch: string;
  repoAddress: string;
  maintainers?: string[];
  channelId?: string | null;
  eventContent?: string;
  eventTags?: string[][];
};

export type Project = {
  id: string;
  dtag: string;
  name: string;
  description: string;
  owner: string;
  createdAt: number;
  projectChannelId: string | null;
  status: string;
  projectAddress: string;
  primaryRepositoryAddress: string | null;
  repositoryAddresses: string[];
  repositoryRelayHints?: Record<string, string>;
  repositories: Repository[];
  unavailableRepositoryAddresses?: string[];
  visibility?: "listed" | "unlisted";
  legacy: boolean;
};

type BuildProjectReadModelsInput = {
  projectEvents: RelayEvent[];
  repositoryEvents: RelayEvent[];
  /** NIP-09 kind:5 deletion events relevant to projects and repositories. */
  deletionEvents?: RelayEvent[];
  relayOrigin?: string | null;
  hiddenAddresses?: ReadonlySet<string>;
};

const MAX_D_TAG_BYTES = 1_024;

function getTag(event: RelayEvent, name: string): string | undefined {
  const value = event.tags.find((tag) => tag[0] === name)?.[1];
  return typeof value === "string" && value.length > 0 ? value : undefined;
}

function getAllTags(event: RelayEvent, name: string): string[] {
  return event.tags
    .filter(
      (tag) =>
        tag[0] === name && typeof tag[1] === "string" && tag[1].length > 0,
    )
    .map((tag) => tag[1]);
}

function getAllTagValues(event: RelayEvent, name: string): string[] {
  return event.tags
    .filter((tag) => tag[0] === name)
    .flatMap((tag) => tag.slice(1))
    .filter((value) => value.length > 0);
}

function getCloneUrls(event: RelayEvent): string[] {
  const tag = event.tags.find((candidate) => candidate[0] === "clone");
  return tag?.slice(1).filter((value) => value.length > 0) ?? [];
}

function isValidDTag(value: string): boolean {
  return (
    value.length > 0 &&
    new TextEncoder().encode(value).byteLength <= MAX_D_TAG_BYTES
  );
}

function isValidPubkey(value: string): boolean {
  return /^[a-fA-F0-9]{64}$/.test(value);
}

/**
 * Validates a pubkey as a lowercase-only 64-hex string, per NIP-MP rule
 * `member-coordinate-malformed`: owner hex MUST be lowercase so that `#a`
 * filter matching (which is byte-exact) can resolve the coordinate.
 */
function isValidProjectMemberOwner(value: string): boolean {
  return /^[0-9a-f]{64}$/.test(value);
}

/** NIP-MP rule `member-cap`: a project may carry at most 64 member `a` tags. */
export const MAX_PROJECT_MEMBERS = 64;

export function isValidProjectChannelId(value: string): boolean {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
    value,
  );
}

const SINGLETON_METADATA_TAGS = [
  "name",
  "description",
  "buzz-channel",
  "buzz-visibility",
] as const;

const MAX_METADATA_TAG_BYTES: Record<string, number> = {
  name: 256,
  description: 2_048,
  "buzz-channel": 256,
  "buzz-visibility": 256,
};

/**
 * Validates the NIP-MP tag/content envelope for a kind:30621 project event.
 * Shared by both the read parser (`eventToExplicitProject`) and the write
 * helper (`buildProjectPatchTemplate`) so Desktop's input and output agree on
 * which heads are valid.
 *
 * Throws a descriptive error on the first violation found.
 */
export function validateProjectEventEnvelope(
  tags: string[][],
  content: string,
): void {
  // NIP-MP rule `d-cardinality`: exactly one `d` tag is required.
  const dTags = tags.filter((tag) => tag[0] === "d");
  if (dTags.length !== 1 || !dTags[0][1]) {
    throw new Error(
      `NIP-MP: expected exactly one non-empty 'd' tag, found ${dTags.length}.`,
    );
  }
  const dtag = dTags[0][1];
  if (!isValidDTag(dtag)) {
    throw new Error(`NIP-MP: 'd' tag value exceeds the maximum byte length.`);
  }

  // NIP-MP rule `metadata-cardinality`: at most one each of the singleton tags.
  const encoder = new TextEncoder();
  for (const tagName of SINGLETON_METADATA_TAGS) {
    const count = tags.filter((tag) => tag[0] === tagName).length;
    if (count > 1) {
      throw new Error(
        `NIP-MP: duplicate '${tagName}' tag — at most one is permitted.`,
      );
    }
  }

  // NIP-MP rule `metadata-length`: per-field byte caps.
  for (const [tagName, maxBytes] of Object.entries(MAX_METADATA_TAG_BYTES)) {
    const value = tags.find((tag) => tag[0] === tagName)?.[1];
    if (value !== undefined && encoder.encode(value).byteLength > maxBytes) {
      throw new Error(
        `NIP-MP: '${tagName}' tag value exceeds the ${maxBytes}-byte limit.`,
      );
    }
  }

  // NIP-MP rule `member-cap`: at most 64 `a` membership tags.
  const memberTags = tags.filter((tag) => tag[0] === "a");
  if (memberTags.length > MAX_PROJECT_MEMBERS) {
    throw new Error(
      `NIP-MP: project exceeds the ${MAX_PROJECT_MEMBERS}-member limit.`,
    );
  }

  // NIP-MP rule `member-coordinate-malformed` + `member-arity`:
  // each `a` tag must be a valid repository coordinate with a lowercase owner,
  // and must carry 2 or 3 elements (address + optional relay hint).
  const seenAddresses = new Set<string>();
  for (const tag of memberTags) {
    const address = tag[1];
    if (!address) {
      throw new Error("NIP-MP: 'a' tag is missing a repository address.");
    }
    if (tag.length !== 2 && tag.length !== 3) {
      throw new Error(
        `NIP-MP: 'a' tag for '${address}' must have 2 or 3 elements.`,
      );
    }
    const parsed = parseRepositoryAddress(address);
    if (!parsed) {
      throw new Error(
        `NIP-MP: invalid repository address '${address}' — expected '30617:<lowercase-hex64>:<dtag>'.`,
      );
    }
    if (seenAddresses.has(address)) {
      throw new Error(`NIP-MP: duplicate repository address '${address}'.`);
    }
    seenAddresses.add(address);
  }

  void content; // content is preserved verbatim; no constraint in NIP-MP.
}

function deduplicateAddressableEvents(events: RelayEvent[]): RelayEvent[] {
  const latest = new Map<string, RelayEvent>();
  for (const event of events) {
    const dtag = getTag(event, "d");
    if (!dtag) continue;
    const key = `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`;
    const current = latest.get(key);
    if (
      !current ||
      event.created_at > current.created_at ||
      (event.created_at === current.created_at && event.id < current.id)
    ) {
      latest.set(key, event);
    }
  }
  return [...latest.values()];
}

function parseRepositoryAddress(
  value: string,
): { owner: string; dtag: string } | null {
  const firstSeparator = value.indexOf(":");
  const secondSeparator = value.indexOf(":", firstSeparator + 1);
  if (
    value.slice(0, firstSeparator) !== String(KIND_REPO_ANNOUNCEMENT) ||
    secondSeparator < 0
  ) {
    return null;
  }

  const owner = value.slice(firstSeparator + 1, secondSeparator);
  const dtag = value.slice(secondSeparator + 1);
  return isValidProjectMemberOwner(owner) && isValidDTag(dtag)
    ? { owner, dtag }
    : null;
}

export function eventToRepository(
  event: RelayEvent,
  relayOrigin?: string | null,
): Repository | null {
  const dtag = getTag(event, "d");
  if (
    event.kind !== KIND_REPO_ANNOUNCEMENT ||
    !dtag ||
    !isValidDTag(dtag) ||
    !isValidPubkey(event.pubkey)
  ) {
    return null;
  }

  const owner = event.pubkey.toLowerCase();
  const setupUsers = getAllTags(event, "auth");
  const channel = getTag(event, "buzz-channel");
  return {
    id: `${owner}:${dtag}`,
    dtag,
    name: getTag(event, "name") ?? dtag,
    description: getTag(event, "description") ?? event.content ?? "",
    cloneUrls: effectiveCloneUrls(
      getCloneUrls(event),
      relayOrigin,
      owner,
      dtag,
    ),
    webUrl: getTag(event, "web") ?? null,
    owner,
    contributors: [...new Set([...getAllTags(event, "p"), ...setupUsers])],
    createdAt: event.created_at,
    status: getTag(event, "status") ?? "active",
    defaultBranch: getTag(event, "default-branch") ?? "main",
    repoAddress: `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`,
    channelId: channel && isValidProjectChannelId(channel) ? channel : null,
    eventContent: event.content,
    eventTags: event.tags.map((tag) => [...tag]),
    maintainers: getAllTagValues(event, "maintainers")
      .map((maintainer) => maintainer.toLowerCase())
      .filter(isValidPubkey),
  };
}

export function eventToExplicitProject(
  event: RelayEvent,
  repositoriesByAddress: ReadonlyMap<string, Repository>,
  visibleRepositoriesByAddress: ReadonlyMap<string, Repository>,
): Project | null {
  if (
    event.kind !== KIND_PROJECT_ANNOUNCEMENT ||
    !isValidPubkey(event.pubkey)
  ) {
    return null;
  }

  // Delegate all NIP-MP envelope validation to the shared validator so the
  // read parser and the write helper (`buildProjectPatchTemplate`) agree on
  // which heads are valid. The parser rejects invalid events silently (returns
  // null) while the write helper throws, so wrap in a try/catch here.
  try {
    validateProjectEventEnvelope(event.tags, event.content);
  } catch {
    return null;
  }

  // After validation we know: exactly one `d` tag with a valid value, at most
  // 64 `a` tags with valid repo coordinates, no duplicate addresses, and all
  // singleton metadata tags within their byte caps.
  const dtag = event.tags.find((tag) => tag[0] === "d")?.[1] ?? "";
  const membershipTags = event.tags.filter((tag) => tag[0] === "a");
  const repositoryAddresses: string[] = [];
  const repositoryRelayHints: Record<string, string> = {};
  for (const membershipTag of membershipTags) {
    const repositoryAddress = membershipTag[1];
    repositoryAddresses.push(repositoryAddress);
    if (membershipTag[2]) {
      repositoryRelayHints[repositoryAddress] = membershipTag[2];
    }
  }
  repositoryAddresses.sort();
  const primaryRepositoryAddress =
    repositoryAddresses.find(
      (address) => visibleRepositoriesByAddress.get(address)?.dtag === dtag,
    ) ??
    repositoryAddresses.find((address) =>
      visibleRepositoriesByAddress.has(address),
    ) ??
    null;

  const owner = event.pubkey.toLowerCase();
  const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${owner}:${dtag}`;

  const rawVisibility = getTag(event, "buzz-visibility");
  const visibility =
    rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const);
  const channel = getTag(event, "buzz-channel");
  return {
    id: projectAddress,
    dtag,
    name: getTag(event, "name") ?? dtag,
    description: getTag(event, "description") ?? "",
    owner,
    createdAt: event.created_at,
    projectChannelId:
      channel && isValidProjectChannelId(channel) ? channel : null,
    status: visibility === "listed" ? "active" : "unlisted",
    projectAddress,
    primaryRepositoryAddress,
    repositoryAddresses,
    repositoryRelayHints,
    repositories: repositoryAddresses.flatMap((address) => {
      const repository = visibleRepositoriesByAddress.get(address);
      return repository ? [repository] : [];
    }),
    unavailableRepositoryAddresses: repositoryAddresses.filter(
      (address) => !repositoriesByAddress.has(address),
    ),
    visibility,
    legacy: false,
  };
}

function repositoryToLegacyProject(repository: Repository): Project {
  return {
    id: repository.repoAddress,
    dtag: repository.dtag,
    name: repository.name,
    description: repository.description,
    owner: repository.owner,
    createdAt: repository.createdAt,
    projectChannelId: null,
    status: repository.status,
    projectAddress: repository.repoAddress,
    primaryRepositoryAddress: repository.repoAddress,
    repositoryAddresses: [repository.repoAddress],
    repositoryRelayHints: {},
    repositories: [repository],
    unavailableRepositoryAddresses: [],
    visibility: "listed",
    legacy: true,
  };
}

/**
 * Builds the set of addressable coordinates that have been authoritatively
 * deleted per NIP-09 semantics: the deletion signer must equal the coordinate
 * owner, and the deletion's `created_at` must be ≥ the live head's timestamp.
 * Returns a `Map<coordinate, deletedAt>` for threshold comparison.
 */
function buildDeletionThresholds(
  deletionEvents: RelayEvent[],
): Map<string, number> {
  const thresholds = new Map<string, number>();
  for (const event of deletionEvents) {
    const signer = event.pubkey.toLowerCase();
    for (const tag of event.tags) {
      if (tag[0] !== "a" || !tag[1]) continue;
      const coordinate = tag[1];
      // The signer must be the owner of the coordinate.
      const firstColon = coordinate.indexOf(":");
      const secondColon = coordinate.indexOf(":", firstColon + 1);
      if (firstColon < 0 || secondColon < 0) continue;
      const owner = coordinate.slice(firstColon + 1, secondColon).toLowerCase();
      if (owner !== signer) continue;
      // Keep the latest (most permissive) deletion threshold.
      const existing = thresholds.get(coordinate);
      if (existing === undefined || event.created_at > existing) {
        thresholds.set(coordinate, event.created_at);
      }
    }
  }
  return thresholds;
}

export function buildProjectReadModels({
  projectEvents,
  repositoryEvents,
  deletionEvents = [],
  relayOrigin,
  hiddenAddresses = new Set(),
}: BuildProjectReadModelsInput): Project[] {
  const deletionThresholds = buildDeletionThresholds(deletionEvents);

  /** Returns true when the event's addressable coordinate has been deleted. */
  function isDeleted(event: RelayEvent): boolean {
    const dtag = event.tags.find((tag) => tag[0] === "d")?.[1];
    if (!dtag) return false;
    const coordinate = `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`;
    const threshold = deletionThresholds.get(coordinate);
    return threshold !== undefined && event.created_at <= threshold;
  }

  const repositories = deduplicateAddressableEvents(repositoryEvents)
    .filter((event) => !isDeleted(event))
    .flatMap((event) => {
      const repository = eventToRepository(event, relayOrigin);
      return repository ? [repository] : [];
    });
  const repositoriesByAddress = new Map(
    repositories.map((repository) => [repository.repoAddress, repository]),
  );
  const visibleRepositories = repositories.filter(
    (repository) => !hiddenAddresses.has(repository.repoAddress),
  );
  const visibleRepositoriesByAddress = new Map(
    visibleRepositories.map((repository) => [
      repository.repoAddress,
      repository,
    ]),
  );

  const explicitProjects = deduplicateAddressableEvents(projectEvents)
    .filter((event) => !isDeleted(event))
    .flatMap((event) => {
      const project = eventToExplicitProject(
        event,
        repositoriesByAddress,
        visibleRepositoriesByAddress,
      );
      return project &&
        project.visibility === "listed" &&
        !hiddenAddresses.has(project.projectAddress)
        ? [project]
        : [];
    });
  const claimedRepositories = new Set(
    explicitProjects.flatMap((project) =>
      project.repositoryAddresses.filter((address) => {
        const repository = repositoriesByAddress.get(address);
        return (
          repository &&
          (repository.owner === project.owner ||
            repository.maintainers?.includes(project.owner))
        );
      }),
    ),
  );
  const legacyProjects = visibleRepositories
    .filter((repository) => !claimedRepositories.has(repository.repoAddress))
    .map(repositoryToLegacyProject);

  return [...explicitProjects, ...legacyProjects].sort(
    (left, right) => right.createdAt - left.createdAt,
  );
}

export function selectProjectRepository(
  project: Project | null | undefined,
  requestedRepositoryId: string | null | undefined,
): Repository | null {
  if (!project) return null;

  const requested = requestedRepositoryId
    ? project.repositories.find(
        (repository) => repository.id === requestedRepositoryId,
      )
    : null;
  if (requested) return requested;

  return (
    project.repositories.find(
      (repository) =>
        repository.repoAddress === project.primaryRepositoryAddress,
    ) ??
    project.repositories[0] ??
    null
  );
}

/** Returns the optimistic read model after adding a resolved repository. */
export function addRepositoryToProject(
  project: Project,
  repository: Repository,
  createdAt: number,
): Project {
  const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${project.owner}:${project.dtag}`;
  const repositoryAddresses = [
    ...new Set([...project.repositoryAddresses, repository.repoAddress]),
  ].sort();
  const repositories = [
    ...project.repositories.filter(
      (candidate) => candidate.repoAddress !== repository.repoAddress,
    ),
    repository,
  ].sort((left, right) => left.repoAddress.localeCompare(right.repoAddress));

  return {
    ...project,
    id: projectAddress,
    createdAt,
    legacy: false,
    projectAddress,
    primaryRepositoryAddress:
      repositories.find((candidate) => candidate.dtag === project.dtag)
        ?.repoAddress ??
      repositories[0]?.repoAddress ??
      null,
    repositoryAddresses,
    repositories,
    unavailableRepositoryAddresses:
      project.unavailableRepositoryAddresses?.filter(
        (address) => address !== repository.repoAddress,
      ) ?? [],
  };
}
