// Shared types/constants for the /gitea workspace panels. The wire shapes mirror what the officer-gitea
// sidecar passes through from the instance's own /api/v1 — so they keep Gitea's field names verbatim,
// snake_case and all (`full_name`, `html_url`, `updated_at`). Renaming them here would put this file and
// Gitea's swagger in disagreement about what a field is called, which is exactly the confusion you don't
// want when a minor version adds one.
//
// Only the fields actually rendered are declared. Gitea's Repository object has ~70; typing all of them
// would be a second copy of a spec that moves. Every one of these was read off the live instance's
// swagger definitions rather than from memory.

export const GITEA_SECTIONS = [
  { id: 'repositories', label: 'Repositories' },
  { id: 'issues', label: 'Issues' },
  { id: 'pulls', label: 'Pull requests' },
  { id: 'notifications', label: 'Notifications' },
  { id: 'explore', label: 'Explore' },
  { id: 'organizations', label: 'Organizations' },
  { id: 'connection', label: 'Connection' },
] as const;

export type GiteaSectionId = (typeof GITEA_SECTIONS)[number]['id'];

/** Where /gitea lands, and where an unrecognised section redirects to. */
export const DEFAULT_GITEA_SECTION: GiteaSectionId = 'repositories';

export const isGiteaSection = (value: string | undefined): value is GiteaSectionId =>
  GITEA_SECTIONS.some((s) => s.id === value);

/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const giteaSectionPath = (id: GiteaSectionId) => `/gitea/${id}`;

// ── Repository routes ─────────────────────────────────────────────────────────────────────────────
//
// A repository is addressed by owner + name in the path, mirroring Gitea's own URLs, with the open tab as
// a further segment: /gitea/repo/pastilhas/officer/code. Two things stay in the QUERY string rather than
// the path — the file path being browsed and the git ref — because both are free-form (a file path has
// slashes in it, a branch name can too) and neither is worth a splat route to encode.

export const GITEA_REPO_TABS = [
  { id: 'code', label: 'Code' },
  { id: 'issues', label: 'Issues' },
  { id: 'pulls', label: 'Pull requests' },
  { id: 'commits', label: 'Commits' },
  { id: 'branches', label: 'Branches' },
  { id: 'releases', label: 'Releases' },
] as const;

export type GiteaRepoTab = (typeof GITEA_REPO_TABS)[number]['id'];

export const DEFAULT_GITEA_REPO_TAB: GiteaRepoTab = 'code';

export const isGiteaRepoTab = (value: string | undefined): value is GiteaRepoTab =>
  GITEA_REPO_TABS.some((t) => t.id === value);

type RepoPathOptions = { path?: string; ref?: string; tab?: GiteaRepoTab; item?: number | string };

export function giteaRepoPath(owner: string, name: string, options: RepoPathOptions = {}): string {
  const { path, ref, tab = DEFAULT_GITEA_REPO_TAB, item } = options;
  const base = `/gitea/repo/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${tab}`;
  const withItem = item == null ? base : `${base}/${encodeURIComponent(String(item))}`;
  const query = new URLSearchParams();
  if (path) query.set('path', path);
  if (ref) query.set('ref', ref);
  const qs = query.toString();
  return qs ? `${withItem}?${qs}` : withItem;
}

// ── Domain objects ────────────────────────────────────────────────────────────────────────────────

export type GiteaUser = {
  id: number;
  login: string;
  full_name?: string;
  email?: string;
  avatar_url?: string;
  html_url?: string;
};

export type GiteaRepo = {
  id: number;
  name: string;
  full_name: string;
  description?: string;
  private: boolean;
  fork: boolean;
  mirror?: boolean;
  template?: boolean;
  archived?: boolean;
  empty?: boolean;
  html_url: string;
  ssh_url?: string;
  clone_url?: string;
  website?: string;
  default_branch?: string;
  language?: string;
  stars_count?: number;
  watchers_count?: number;
  forks_count?: number;
  open_issues_count?: number;
  open_pr_counter?: number;
  release_counter?: number;
  size?: number;
  created_at?: string;
  updated_at?: string;
  owner?: GiteaUser;
  parent?: GiteaRepo;
  permissions?: { admin?: boolean; push?: boolean; pull?: boolean };
  has_issues?: boolean;
  has_pull_requests?: boolean;
  has_releases?: boolean;
  has_wiki?: boolean;
};

export type GiteaLabel = {
  id: number;
  name: string;
  color: string;
  description?: string;
};

export type GiteaMilestone = {
  id: number;
  title: string;
  description?: string;
  state: string;
  open_issues?: number;
  closed_issues?: number;
  due_on?: string;
};

export type GiteaAttachment = {
  id: number;
  name: string;
  size: number;
  download_count: number;
  created_at: string;
  browser_download_url: string;
};

/** Present on an Issue only when that issue is really a pull request — this is how Gitea distinguishes them. */
export type GiteaPullRequestMeta = { merged: boolean; merged_at?: string; html_url?: string };

export type GiteaIssue = {
  id: number;
  number: number;
  title: string;
  body?: string;
  state: string;
  html_url: string;
  comments: number;
  created_at: string;
  updated_at?: string;
  closed_at?: string;
  due_date?: string;
  user?: GiteaUser;
  assignees?: GiteaUser[];
  labels?: GiteaLabel[];
  milestone?: GiteaMilestone;
  pull_request?: GiteaPullRequestMeta;
  repository?: { id: number; name: string; owner: string; full_name: string };
  is_locked?: boolean;
};

export type GiteaComment = {
  id: number;
  body: string;
  html_url: string;
  created_at: string;
  updated_at?: string;
  user?: GiteaUser;
};

export type GiteaBranchInfo = { label: string; ref: string; sha: string; repo_id?: number; repo?: GiteaRepo };

export type GiteaPullRequest = {
  id: number;
  number: number;
  title: string;
  body?: string;
  state: string;
  html_url: string;
  diff_url?: string;
  draft?: boolean;
  merged?: boolean;
  merged_at?: string;
  mergeable?: boolean;
  merge_base?: string;
  comments?: number;
  review_comments?: number;
  additions?: number;
  deletions?: number;
  changed_files?: number;
  created_at: string;
  updated_at?: string;
  closed_at?: string;
  user?: GiteaUser;
  merged_by?: GiteaUser;
  assignees?: GiteaUser[];
  requested_reviewers?: GiteaUser[];
  labels?: GiteaLabel[];
  milestone?: GiteaMilestone;
  base?: GiteaBranchInfo;
  head?: GiteaBranchInfo;
};

/** One entry of a pull request's changed-file list. `status` is added | modified | removed | renamed. */
export type GiteaChangedFile = {
  filename: string;
  previous_filename?: string;
  status: string;
  additions: number;
  deletions: number;
  changes: number;
  html_url?: string;
  raw_url?: string;
  contents_url?: string;
};

export type GiteaCommitUser = { name?: string; email?: string; date?: string };

export type GiteaCommit = {
  sha: string;
  html_url: string;
  created?: string;
  author?: GiteaUser;
  committer?: GiteaUser;
  parents?: { sha: string; url?: string }[];
  stats?: { additions?: number; deletions?: number; total?: number };
  commit: {
    message: string;
    url?: string;
    author?: GiteaCommitUser;
    committer?: GiteaCommitUser;
  };
};

export type GiteaBranch = {
  name: string;
  protected?: boolean;
  user_can_push?: boolean;
  user_can_merge?: boolean;
  commit?: { id?: string; message?: string; timestamp?: string; author?: GiteaCommitUser };
};

export type GiteaTag = {
  name: string;
  id?: string;
  message?: string;
  commit?: { sha?: string; url?: string; created?: string };
  tarball_url?: string;
  zipball_url?: string;
};

export type GiteaRelease = {
  id: number;
  tag_name: string;
  target_commitish?: string;
  name: string;
  body?: string;
  url: string;
  html_url: string;
  tarball_url?: string;
  zipball_url?: string;
  draft: boolean;
  prerelease: boolean;
  created_at: string;
  published_at?: string;
  author?: GiteaUser;
  assets?: GiteaAttachment[];
};

/**
 * One entry from the contents API. Gitea answers an ARRAY of these for a directory and a SINGLE one for a
 * file — same shape either way, which is why there is one type. `content` is base64 and only present for a
 * file (and omitted entirely for very large ones); `type` is file | dir | symlink | submodule.
 */
export type GiteaContentsEntry = {
  name: string;
  path: string;
  sha: string;
  type: string;
  size: number;
  encoding?: string;
  content?: string;
  target?: string;
  submodule_git_url?: string;
  html_url?: string;
  download_url?: string;
  last_commit_sha?: string;
  last_commit_message?: string;
  last_committer_date?: string;
};

export type GiteaOrg = {
  id: number;
  username: string;
  full_name?: string;
  description?: string;
  website?: string;
  location?: string;
  avatar_url?: string;
  visibility?: string;
};

export type GiteaNotificationSubject = {
  title: string;
  url?: string;
  html_url?: string;
  latest_comment_url?: string;
  latest_comment_html_url?: string;
  /** Issue | Pull | Commit | Repository */
  type: string;
  state?: string;
};

export type GiteaNotification = {
  id: number;
  unread: boolean;
  pinned: boolean;
  updated_at: string;
  url: string;
  subject: GiteaNotificationSubject;
  repository?: GiteaRepo;
};

/** `/repos/search` and `/repos/issues/search` wrap their results; the plain list endpoints do not. */
export type GiteaSearchResult<T> = { ok: boolean; data: T[] };

// ── Formatting helpers ────────────────────────────────────────────────────────────────────────────

export function timeAgo(iso?: string | null): string {
  if (!iso) return '';
  const ms = Date.now() - new Date(iso).getTime();
  if (!Number.isFinite(ms)) return '';
  if (ms < 0) return 'just now';
  const mins = Math.floor(ms / 60_000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins}m ago`;
  const hours = Math.floor(mins / 60);
  if (hours < 24) return `${hours}h ago`;
  const days = Math.floor(hours / 24);
  if (days < 30) return `${days}d ago`;
  const months = Math.floor(days / 30);
  return months < 12 ? `${months}mo ago` : `${Math.floor(months / 12)}y ago`;
}

export const fullDate = (iso?: string | null): string => (iso ? new Date(iso).toLocaleString() : '');

export function formatBytes(bytes?: number): string {
  if (!bytes) return '0 B';
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
  const value = bytes / 1024 ** i;
  return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`;
}

/** Everything before the first blank line — Gitea shows this as a commit's title and the rest as its body. */
export const commitTitle = (message: string): string => message.split('\n')[0] ?? '';

export const commitBody = (message: string): string => message.split('\n').slice(1).join('\n').trim();

export const shortSha = (sha?: string): string => (sha ?? '').slice(0, 7);

/**
 * A label's contrast colour. Gitea stores label colours as a bare hex string and picks black or white text
 * off the perceived luminance; matching that formula keeps our chips legible on the same palette theirs is.
 */
export function labelTextColor(hex: string): string {
  const clean = hex.replace('#', '');
  if (clean.length !== 6) return '#fff';
  const r = parseInt(clean.slice(0, 2), 16);
  const g = parseInt(clean.slice(2, 4), 16);
  const b = parseInt(clean.slice(4, 6), 16);
  return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#000' : '#fff';
}

/**
 * Decode the base64 the contents API returns. `atob` yields one char per BYTE, so anything non-ASCII comes
 * back mojibake unless those bytes are handed to a UTF-8 decoder — which is most source files with a name
 * or a symbol in them.
 */
export function decodeBase64(data: string): string {
  const binary = atob(data.replace(/\s/g, ''));
  const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

/**
 * Rewrite the origin of every instance-generated URL in a response onto the URL the connection was
 * configured with.
 *
 * This is not defensive tidying — it is required. Gitea builds `html_url` and `avatar_url` from its own
 * `ROOT_URL` setting, and an instance behind a reverse proxy very often has that set to whatever it is
 * bound to internally. The instance this was built against answers `http://localhost:9004/officerdev/landing`
 * for a repository the owner reaches at `https://gitea.pastilhas.dev/officerdev/landing`, so every avatar
 * would be a broken image and every "open in Gitea" link would go nowhere. Some endpoints (contents) build
 * their URLs from the request host instead and are already right — rewriting those is a no-op, which is
 * what makes a blanket rewrite safe.
 *
 * Only fields Gitea itself minted are touched. `website`, `external_tracker` and the like are genuinely
 * somewhere else and are left alone — which is why this is a key allow-list and not "every string that
 * parses as a URL".
 */
const REBASED_KEYS = new Set(['html_url', 'avatar_url', 'download_url', 'browser_download_url', 'clone_url']);

/**
 * Hosts a browser on some other machine cannot resolve. The connection URL is a SERVER-side dial address —
 * the form says so ("localhost here means the machine Officer runs on") — so it is routinely one of these,
 * while the page asking for the image is on `PUBLIC_URL`. Rebasing onto one of these would replace a URL
 * the browser can fetch with one it cannot.
 */
export function isUnreachableFromBrowser(host: string): boolean {
  const h = host.toLowerCase().replace(/^\[|\]$/g, '');
  if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
  if (h === '::1' || h === '0.0.0.0' || h.startsWith('127.')) return true;
  return /^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h);
}

/**
 * Rebase instance-minted URLs onto `origin`, IN ONE DIRECTION ONLY: a URL the browser cannot reach is moved
 * onto one it can, never the reverse.
 *
 * The direction matters because an instance can answer with two different origins at once, and the one
 * matching the connection URL is not necessarily the good one. Gitea stamps most URLs from its own
 * configured `ROOT_URL` — the public address — but some endpoints echo the REQUEST host instead, and
 * Officer dials the instance directly rather than through its reverse proxy. So dialling
 * `http://localhost:9004` an instance whose `ROOT_URL` is `https://gitea.pastilhas.dev` yields
 * `/contents` on the public host and `/user`, `/repos` on loopback. An unconditional rewrite onto the
 * connection URL therefore *broke* the good half to match the bad, turning working https links into dead
 * loopback ones and tripping mixed content on the way. Verified against the live instance and its
 * `app.ini` on 2026-08-06.
 *
 * So: only a private/loopback URL is rewritten, and only when `origin` is itself publicly reachable. When
 * the connection URL is a dial address there is nothing better to offer, and the correct move is to change
 * nothing and let the connection screen say why.
 */
export function retargetUrls<T>(value: T, origin: string): T {
  if (!origin) return value;
  let base: URL;
  try {
    base = new URL(origin);
  } catch {
    return value;
  }
  if (isUnreachableFromBrowser(base.hostname)) return value;
  return walk(value, base) as T;
}

function walk(value: unknown, base: URL): unknown {
  if (Array.isArray(value)) return value.map((entry) => walk(entry, base));
  if (!value || typeof value !== 'object') return value;

  const out: Record<string, unknown> = {};
  for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
    out[key] = REBASED_KEYS.has(key) && typeof entry === 'string' ? rebase(entry, base) : walk(entry, base);
  }
  return out;
}

function rebase(url: string, base: URL): string {
  try {
    const parsed = new URL(url);
    // ssh:// and git:// clone URLs carry a different scheme and port; rewriting them produces something
    // that is not a clone URL at all.
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return url;
    // Already pointing where the owner configured — this endpoint built its URL from the request host and
    // is right. Leaving it untouched is also what keeps a subpath install from having its prefix doubled.
    if (parsed.origin === base.origin) return url;
    // Reachable already. It may be a different host than the connection URL (this instance has two), but a
    // URL the browser can load is not something to improve on — see the direction note above.
    if (!isUnreachableFromBrowser(parsed.hostname)) return url;
    const prefix = base.pathname.replace(/\/+$/, '');
    return `${base.origin}${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
  } catch {
    return url;
  }
}
